1
0
Fork 0

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

Conflicts:
	src/LanguageServer.php
pull/189/head
Michal Niewrzal 2016-12-07 11:01:25 +01:00
commit 133e2c7913
41 changed files with 1574 additions and 306 deletions

View File

@ -13,5 +13,5 @@ indent_size = 2
[composer.json] [composer.json]
indent_size = 4 indent_size = 4
[*.md] [{*.md,fixtures/**}]
trim_trailing_whitespace = false trim_trailing_whitespace = false

View File

@ -68,10 +68,12 @@ if (!empty($options['tcp'])) {
exit(1); exit(1);
} else if ($pid === 0) { } else if ($pid === 0) {
// Child process // Child process
$ls = new LanguageServer( $reader = new ProtocolStreamReader($socket);
new ProtocolStreamReader($socket), $writer = new ProtocolStreamWriter($socket);
new ProtocolStreamWriter($socket) $reader->on('close', function () {
); fwrite(STDOUT, "Connection closed\n");
});
$ls = new LanguageServer($reader, $writer);
Loop\run(); Loop\run();
// Just for safety // Just for safety
exit(0); exit(0);

View File

@ -24,11 +24,11 @@
"bin": ["bin/php-language-server.php"], "bin": ["bin/php-language-server.php"],
"require": { "require": {
"php": ">=7.0", "php": ">=7.0",
"nikic/php-parser": "^3.0.0beta2", "nikic/php-parser": "dev-master#e52ffc4447e034514339a03b450aab9cd625e37c",
"phpdocumentor/reflection-docblock": "^3.0", "phpdocumentor/reflection-docblock": "^3.0",
"sabre/event": "^5.0", "sabre/event": "^5.0",
"felixfbecker/advanced-json-rpc": "^2.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", "netresearch/jsonmapper": "^1.0",
"webmozart/path-util": "^2.3", "webmozart/path-util": "^2.3",
"webmozart/glob": "^4.1", "webmozart/glob": "^4.1",

View File

@ -0,0 +1,3 @@
<?php
TestClass::TE

View File

@ -0,0 +1,9 @@
<?php
namespace Whatever;
use TestNamespace\{TestClass, TestInterface};
\TestC
class OtherClass {}

View File

View File

@ -0,0 +1 @@
<

View File

@ -0,0 +1,3 @@
<?php
cl

View File

@ -0,0 +1,5 @@
<?php
namespace SomeNamespace {}
SomeNa

View File

@ -0,0 +1,4 @@
<?php
$obj = new TestClass;
$obj->

View File

@ -0,0 +1,4 @@
<?php
$obj = new TestClass;
$obj->t

View File

@ -0,0 +1,3 @@
<?php
TestClass::

View File

@ -0,0 +1,3 @@
<?php
TestClass::st

View File

@ -0,0 +1,3 @@
<?php
TestClass::$st

View File

@ -0,0 +1,9 @@
<?php
namespace Whatever;
use TestNamespace\{TestClass, TestInterface};
TestC
class OtherClass {}

View File

@ -0,0 +1,7 @@
<?php
namespace Whatever;
use TestNamespace\{TestClass, TestInterface};
$obj = new

View File

@ -0,0 +1,10 @@
<?php
/**
* @param string|null $param A parameter
*/
function test(string $param = null)
{
$var = 123;
$
}

View File

@ -0,0 +1,10 @@
<?php
/**
* @param string|null $param A parameter
*/
function test(string $param = null)
{
$var = 123;
$p
}

385
src/CompletionProvider.php Normal file
View File

@ -0,0 +1,385 @@
<?php
declare(strict_types = 1);
namespace LanguageServer;
use PhpParser\Node;
use phpDocumentor\Reflection\Types;
use LanguageServer\Protocol\{
TextEdit,
Range,
Position,
SymbolKind,
CompletionList,
CompletionItem,
CompletionItemKind
};
class CompletionProvider
{
const KEYWORDS = [
'?>',
'__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('<?php', CompletionItemKind::KEYWORD);
$item->textEdit = new TextEdit(
new Range($pos, $pos),
stripStringOverlap($doc->getRange(new Range(new Position(0, 0), $pos)), '<?php')
);
$list->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 <Node\Expr\Variable|Node\Param|Node\Expr\ClosureUse>
*/
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;
}
}

View File

@ -18,17 +18,40 @@ class Definition
* *
* Examples of FQNs: * Examples of FQNs:
* - testFunction() * - testFunction()
* - TestNamespace
* - TestNamespace\TestClass * - TestNamespace\TestClass
* - TestNamespace\TestClass::TEST_CONSTANT * - TestNamespace\TestClass::TEST_CONSTANT
* - TestNamespace\TestClass::staticTestProperty * - TestNamespace\TestClass::$staticTestProperty
* - TestNamespace\TestClass::testProperty * - TestNamespace\TestClass->testProperty
* - TestNamespace\TestClass::staticTestMethod() * - TestNamespace\TestClass::staticTestMethod()
* - TestNamespace\TestClass::testMethod() * - TestNamespace\TestClass->testMethod()
* *
* @var string|null * @var string|null
*/ */
public $fqn; 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 * @var Protocol\SymbolInformation
*/ */

View File

@ -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 * Given any node, returns the Definition object of the symbol that is referenced
* *
@ -106,21 +135,7 @@ class DefinitionResolver
if ($defNode === null) { if ($defNode === null) {
return null; return null;
} }
$def = new Definition; return $this->createDefinitionFromNode($defNode);
// 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;
} }
// Other references are references to a global symbol that have an FQN // Other references are references to a global symbol that have an FQN
// Find out the FQN // Find out the FQN
@ -136,6 +151,31 @@ class DefinitionResolver
return $this->project->getDefinition($fqn, $globalFallback); 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 * 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 * Returns null if the FQN could not be resolved or the reference node references a variable
@ -150,6 +190,7 @@ class DefinitionResolver
if ( if (
$node instanceof Node\Name && ( $node instanceof Node\Name && (
$parent instanceof Node\Stmt\ClassLike $parent instanceof Node\Stmt\ClassLike
|| $parent instanceof Node\Namespace_
|| $parent instanceof Node\Param || $parent instanceof Node\Param
|| $parent instanceof Node\FunctionLike || $parent instanceof Node\FunctionLike
|| $parent instanceof Node\Expr\StaticCall || $parent instanceof Node\Expr\StaticCall
@ -211,7 +252,7 @@ class DefinitionResolver
} else { } else {
$classFqn = substr((string)$varType->getFqsen(), 1); $classFqn = substr((string)$varType->getFqsen(), 1);
} }
$name = $classFqn . '::' . (string)$node->name; $name = $classFqn . '->' . (string)$node->name;
} else if ($parent instanceof Node\Expr\FuncCall) { } else if ($parent instanceof Node\Expr\FuncCall) {
if ($parent->name instanceof Node\Expr) { if ($parent->name instanceof Node\Expr) {
return null; return null;
@ -245,7 +286,11 @@ class DefinitionResolver
$className = (string)$classNode->namespacedName; $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 { } else {
return null; return null;
} }
@ -281,25 +326,34 @@ class DefinitionResolver
/** /**
* Returns the assignment or parameter node where a variable was defined * 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 * @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; $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 // Traverse the AST up
do { do {
// If a function is met, check the parameters and use statements // If a function is met, check the parameters and use statements
if ($n instanceof Node\FunctionLike) { if ($n instanceof Node\FunctionLike) {
foreach ($n->getParams() as $param) { foreach ($n->getParams() as $param) {
if ($param->name === $var->name) { if ($param->name === $name) {
return $param; return $param;
} }
} }
// If it is a closure, also check use statements // If it is a closure, also check use statements
if ($n instanceof Node\Expr\Closure) { if ($n instanceof Node\Expr\Closure) {
foreach ($n->uses as $use) { foreach ($n->uses as $use) {
if ($use->var === $var->name) { if ($use->var === $name) {
return $use; return $use;
} }
} }
@ -310,7 +364,7 @@ class DefinitionResolver
while ($n->getAttribute('previousSibling') && $n = $n->getAttribute('previousSibling')) { while ($n->getAttribute('previousSibling') && $n = $n->getAttribute('previousSibling')) {
if ( if (
($n instanceof Node\Expr\Assign || $n instanceof Node\Expr\AssignOp) ($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; return $n;
} }
@ -327,10 +381,10 @@ class DefinitionResolver
* @param \PhpParser\Node\Expr $expr * @param \PhpParser\Node\Expr $expr
* @return \phpDocumentor\Type * @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 instanceof Node\Expr\Variable || $expr instanceof Node\Expr\ClosureUse) {
if ($expr->name === 'this') { if ($expr instanceof Node\Expr\Variable && $expr->name === 'this') {
return new Types\This; return new Types\This;
} }
// Find variable definition // Find variable definition
@ -385,7 +439,7 @@ class DefinitionResolver
} else { } else {
$classFqn = substr((string)$t->getFqsen(), 1); $classFqn = substr((string)$t->getFqsen(), 1);
} }
$fqn = $classFqn . '::' . $expr->name; $fqn = $classFqn . '->' . $expr->name;
if ($expr instanceof Node\Expr\MethodCall) { if ($expr instanceof Node\Expr\MethodCall) {
$fqn .= '()'; $fqn .= '()';
} }
@ -404,7 +458,11 @@ class DefinitionResolver
if (!($classType instanceof Types\Object_) || $classType->getFqsen() === null || $expr->name instanceof Node\Expr) { if (!($classType instanceof Types\Object_) || $classType->getFqsen() === null || $expr->name instanceof Node\Expr) {
return new Types\Mixed; 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) { if ($expr instanceof Node\Expr\StaticCall) {
$fqn .= '()'; $fqn .= '()';
} }
@ -599,7 +657,7 @@ class DefinitionResolver
* For functions and methods, this is the return type. * For functions and methods, this is the return type.
* For parameters, this is the type of the parameter. * For parameters, this is the type of the parameter.
* For classes and interfaces, this is the class type (object). * 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. * Can also be a compound type.
* If it is unknown, will be Types\Mixed. * If it is unknown, will be Types\Mixed.
* Returns null if the node does not have a type. * Returns null if the node does not have a type.
@ -612,28 +670,35 @@ class DefinitionResolver
if ($node instanceof Node\Param) { if ($node instanceof Node\Param) {
// Parameters // Parameters
$docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); $docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock');
if ( if ($docBlock !== null) {
$docBlock !== null
&& !empty($paramTags = $docBlock->getTagsByName('param'))
&& $paramTags[0]->getType() !== null
) {
// Use @param tag // 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) { if ($node->type !== null) {
// Use PHP7 return type hint // Use PHP7 return type hint
if (is_string($node->type)) { if (is_string($node->type)) {
// Resolve a string like "bool" to a type object // Resolve a string like "bool" to a type object
$type = $this->typeResolver->resolve($node->type); $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);
$type = new Types\Compound([$type, $defaultType]);
} }
} }
// Unknown parameter type if ($node->default !== null) {
return new Types\Mixed; $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) { if ($node instanceof Node\FunctionLike) {
// Functions/methods // Functions/methods
@ -657,16 +722,39 @@ class DefinitionResolver
// Unknown return type // Unknown return type
return new Types\Mixed; return new Types\Mixed;
} }
if ($node instanceof Node\Stmt\PropertyProperty || $node instanceof Node\Const_) { if ($node instanceof Node\Expr\Variable) {
// Property or constant $node = $node->getAttribute('parentNode');
$docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); }
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 ( if (
$docBlock !== null isset($docBlockHolder)
&& ($docBlock = $docBlockHolder->getAttribute('docBlock'))
&& !empty($varTags = $docBlock->getTagsByName('var')) && !empty($varTags = $docBlock->getTagsByName('var'))
&& $varTags[0]->getType() && ($type = $varTags[0]->getType())
) { ) {
// Use @var tag return $type;
return $varTags[0]->getType(); }
// 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: read @property tags of class
// TODO: Try to infer the type from default value / constant value // 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)) { if ($node instanceof Node\Stmt\ClassLike && isset($node->name)) {
// Class, interface or trait declaration // Class, interface or trait declaration
return (string)$node->namespacedName; return (string)$node->namespacedName;
} else if ($node instanceof Node\Stmt\Namespace_) {
return (string)$node->name;
} else if ($node instanceof Node\Stmt\Function_) { } else if ($node instanceof Node\Stmt\Function_) {
// Function: use functionName() as the name // Function: use functionName() as the name
return (string)$node->namespacedName . '()'; return (string)$node->namespacedName . '()';
} else if ($node instanceof Node\Stmt\ClassMethod) { } 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'); $class = $node->getAttribute('parentNode');
if (!isset($class->name)) { if (!isset($class->name)) {
// Ignore anonymous classes // Ignore anonymous classes
return null; 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) { } else if ($node instanceof Node\Stmt\PropertyProperty) {
// Property: use ClassName::propertyName as name $property = $node->getAttribute('parentNode');
$class = $node->getAttribute('parentNode')->getAttribute('parentNode'); $class = $property->getAttribute('parentNode');
if (!isset($class->name)) { if (!isset($class->name)) {
// Ignore anonymous classes // Ignore anonymous classes
return null; 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_) { } else if ($node instanceof Node\Const_) {
$parent = $node->getAttribute('parentNode'); $parent = $node->getAttribute('parentNode');
if ($parent instanceof Node\Stmt\Const_) { if ($parent instanceof Node\Stmt\Const_) {

View File

@ -4,13 +4,14 @@ declare(strict_types = 1);
namespace LanguageServer; namespace LanguageServer;
use LanguageServer\Protocol\{ use LanguageServer\Protocol\{
ServerCapabilities, ServerCapabilities,
ClientCapabilities, ClientCapabilities,
TextDocumentSyncKind, TextDocumentSyncKind,
Message, Message,
MessageType, MessageType,
InitializeResult, InitializeResult,
TextDocumentIdentifier TextDocumentIdentifier,
CompletionOptions
}; };
use AdvancedJsonRpc; use AdvancedJsonRpc;
use Sabre\Event\Promise; use Sabre\Event\Promise;
@ -25,224 +26,232 @@ use function Sabre\Event\Loop\setTimeout;
class LanguageServer extends AdvancedJsonRpc\Dispatcher class LanguageServer extends AdvancedJsonRpc\Dispatcher
{ {
/** /**
* Handles textDocument/* method calls * Handles textDocument/* method calls
* *
* @var Server\TextDocument * @var Server\TextDocument
*/ */
public $textDocument; public $textDocument;
/** /**
* Handles workspace/* method calls * Handles workspace/* method calls
* *
* @var Server\Workspace * @var Server\Workspace
*/ */
public $workspace; public $workspace;
public $telemetry; public $telemetry;
public $window; public $window;
public $completionItem; public $completionItem;
public $codeLens; public $codeLens;
/** /**
* ClientCapabilities * ClientCapabilities
*/ */
private $clientCapabilities; private $clientCapabilities;
private $protocolReader; private $protocolReader;
private $protocolWriter; private $protocolWriter;
private $client; private $client;
/** /**
* The root project path that was passed to initialize() * The root project path that was passed to initialize()
* *
* @var string * @var string
*/ */
private $rootPath; private $rootPath;
private $project; private $project;
public function __construct(ProtocolReader $reader, ProtocolWriter $writer) public function __construct(ProtocolReader $reader, ProtocolWriter $writer)
{ {
parent::__construct($this, '/'); parent::__construct($this, '/');
$this->protocolReader = $reader; $this->protocolReader = $reader;
$this->protocolReader->on('message', function (Message $msg) { $this->protocolReader->on('close', function () {
coroutine(function () use ($msg) { $this->shutdown();
// Ignore responses, this is the handler for requests and notifications $this->exit();
if (AdvancedJsonRpc\Response::isResponse($msg->body)) { });
return; $this->protocolReader->on('message', function (Message $msg) {
} coroutine(function () use ($msg) {
$result = null; // Ignore responses, this is the handler for requests and notifications
$error = null; if (AdvancedJsonRpc\Response::isResponse($msg->body)) {
try { return;
// Invoke the method handler to get a result }
$result = yield $this->dispatch($msg->body); $result = null;
} catch (AdvancedJsonRpc\Error $e) { $error = null;
// If a ResponseError is thrown, send it back in the Response try {
$error = $e; // Invoke the method handler to get a result
} catch (Throwable $e) { $result = yield $this->dispatch($msg->body);
// If an unexpected error occured, send back an INTERNAL_ERROR error response } catch (AdvancedJsonRpc\Error $e) {
$error = new AdvancedJsonRpc\Error( // If a ResponseError is thrown, send it back in the Response
$e->getMessage(), $error = $e;
AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, } catch (Throwable $e) {
null, // If an unexpected error occured, send back an INTERNAL_ERROR error response
$e $error = new AdvancedJsonRpc\Error(
); $e->getMessage(),
} AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR,
// Only send a Response for a Request null,
// Notifications do not send Responses $e
if (AdvancedJsonRpc\Request::isRequest($msg->body)) { );
if ($error !== null) { }
$responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); // Only send a Response for a Request
} else { // Notifications do not send Responses
$responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); if (AdvancedJsonRpc\Request::isRequest($msg->body)) {
} if ($error !== null) {
$this->protocolWriter->write(new Message($responseBody)); $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error);
} } else {
})->otherwise('\\LanguageServer\\crash'); $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result);
}); }
$this->protocolWriter = $writer; $this->protocolWriter->write(new Message($responseBody));
$this->client = new LanguageClient($reader, $writer); }
} })->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. * 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 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 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. * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process.
* @return InitializeResult * @return InitializeResult
*/ */
public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult
{ {
$this->rootPath = $rootPath; $this->rootPath = $rootPath;
$this->clientCapabilities = $capabilities; $this->clientCapabilities = $capabilities;
$this->project = new Project($this->client, $capabilities); $this->project = new Project($this->client, $capabilities);
$this->textDocument = new Server\TextDocument($this->project, $this->client); $this->textDocument = new Server\TextDocument($this->project, $this->client);
$this->workspace = new Server\Workspace($this->project, $this->client); $this->workspace = new Server\Workspace($this->project, $this->client);
// start building project index // start building project index
if ($rootPath !== null) { if ($rootPath !== null) {
$this->indexProject()->otherwise('\\LanguageServer\\crash'); $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')) { $serverCapabilities = new ServerCapabilities();
setTimeout(function () { // Ask the client to return always full documents (because we need to rebuild the AST from scratch)
$this->client->window->showMessage(MessageType::WARNING, 'You are running PHP Language Server with xdebug enabled. This has a major impact on server performance.'); $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL;
}, 1); // 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(); return new InitializeResult($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); /**
} * 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 * A notification to ask the server to exit its process.
* (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
* */
* @return void public function exit()
*/ {
public function shutdown() exit(0);
{ }
unset($this->project);
}
/** /**
* A notification to ask the server to exit its process. * Parses workspace files, one at a time.
* *
* @return void * @return Promise <void>
*/ */
public function exit() private function indexProject(): Promise
{ {
exit(0); return coroutine(function () {
} $textDocuments = yield $this->findPhpFiles();
$count = count($textDocuments);
/** $startTime = microtime(true);
* Parses workspace files, one at a time.
*
* @return Promise <void>
*/
private function indexProject(): Promise
{
return coroutine(function () {
$textDocuments = yield $this->findPhpFiles();
$count = count($textDocuments);
$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) { $duration = (int)(microtime(true) - $startTime);
// Give LS to the chance to handle requests while indexing $mem = (int)(memory_get_usage(true) / (1024 * 1024));
yield timeout(); $this->client->window->logMessage(
$this->client->window->logMessage( MessageType::INFO,
MessageType::LOG, "All $count PHP files parsed in $duration seconds. $mem MiB allocated."
"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)); * Returns all PHP files in the workspace.
$this->client->window->logMessage( * If the client does not support workspace/files, it falls back to searching the file system directly.
MessageType::INFO, *
"All $count PHP files parsed in $duration seconds. $mem MiB allocated." * @return Promise <TextDocumentIdentifier[]>
); */
}); private function findPhpFiles(): Promise
} {
return coroutine(function () {
/** $textDocuments = [];
* Returns all PHP files in the workspace. $pattern = Path::makeAbsolute('**/*.php', $this->rootPath);
* If the client does not support workspace/files, it falls back to searching the file system directly. if ($this->clientCapabilities->xfilesProvider) {
* // Use xfiles request
* @return Promise <TextDocumentIdentifier[]> foreach (yield $this->client->workspace->xfiles() as $textDocument) {
*/ $path = Uri\parse($textDocument->uri)['path'];
private function findPhpFiles(): Promise if (Glob::match($path, $pattern)) {
{ $textDocuments[] = $textDocument;
return coroutine(function () { }
$textDocuments = []; }
$pattern = Path::makeAbsolute('**/*.php', $this->rootPath); } else {
if ($this->clientCapabilities->xfilesProvider) { // Use the file system
// Use xfiles request foreach (new GlobIterator($pattern) as $path) {
foreach (yield $this->client->workspace->xfiles() as $textDocument) { $textDocuments[] = new TextDocumentIdentifier(pathToUri($path));
$path = Uri\parse($textDocument->uri)['path']; yield timeout();
if (Glob::match($path, $pattern)) { }
$textDocuments[] = $textDocument; }
} return $textDocuments;
} });
} else { }
// Use the file system
foreach (new GlobIterator($pattern) as $path) {
$textDocuments[] = new TextDocumentIdentifier(pathToUri($path));
yield timeout();
}
}
return $textDocuments;
});
}
} }

View File

@ -42,13 +42,6 @@ class DefinitionCollector extends NodeVisitorAbstract
return; return;
} }
$this->nodes[$fqn] = $node; $this->nodes[$fqn] = $node;
$def = new Definition; $this->definitions[$fqn] = $this->definitionResolver->createDefinitionFromNode($node, $fqn);
$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;
} }
} }

View File

@ -290,6 +290,9 @@ class PhpDocument
*/ */
public function getNodeAtPosition(Position $position) public function getNodeAtPosition(Position $position)
{ {
if ($this->stmts === null) {
return null;
}
$traverser = new NodeTraverser; $traverser = new NodeTraverser;
$finder = new NodeAtPositionFinder($position); $finder = new NodeAtPositionFinder($position);
$traverser->addVisitor($finder); $traverser->addVisitor($finder);
@ -297,6 +300,22 @@ class PhpDocument
return $finder->node; 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 * Returns the definition node for a fully qualified name
* *

View File

@ -1,7 +1,10 @@
<?php <?php
declare(strict_types = 1);
namespace LanguageServer\Protocol; namespace LanguageServer\Protocol;
use LanguageServer\Definition;
class CompletionItem class CompletionItem
{ {
/** /**
@ -69,6 +72,24 @@ class CompletionItem
*/ */
public $textEdit; public $textEdit;
/**
* An optional array of additional text edits that are applied when
* selecting this completion. Edits must not overlap with the main edit
* nor with themselves.
*
* @var TextEdit[]|null
*/
public $additionalTextEdits;
/**
* An optional command that is executed *after* inserting this completion. *Note* that
* additional modifications to the current document should be described with the
* additionalTextEdits-property.
*
* @var Command|null
*/
public $command;
/** /**
* An data entry field that is preserved on a completion item between * An data entry field that is preserved on a completion item between
* a completion and a completion resolve request. * a completion and a completion resolve request.
@ -76,4 +97,68 @@ class CompletionItem
* @var mixed * @var mixed
*/ */
public $data; public $data;
/**
* @param string $label
* @param int|null $kind
* @param string|null $detail
* @param string|null $documentation
* @param string|null $sortText
* @param string|null $filterText
* @param string|null $insertText
* @param TextEdit|null $textEdit
* @param TextEdit[]|null $additionalTextEdits
* @param Command|null $command
* @param mixed|null $data
*/
public function __construct(
string $label = null,
int $kind = null,
string $detail = null,
string $documentation = null,
string $sortText = null,
string $filterText = null,
string $insertText = null,
TextEdit $textEdit = null,
array $additionalTextEdits = null,
Command $command = null,
$data = null
) {
$this->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;
}
} }

View File

@ -13,7 +13,7 @@ abstract class CompletionItemKind
const CONSTRUCTOR = 4; const CONSTRUCTOR = 4;
const FIELD = 5; const FIELD = 5;
const VARIABLE = 6; const VARIABLE = 6;
const _CLASS = 7; const CLASS_ = 7;
const INTERFACE = 8; const INTERFACE = 8;
const MODULE = 9; const MODULE = 9;
const PROPERTY = 10; const PROPERTY = 10;
@ -25,4 +25,46 @@ abstract class CompletionItemKind
const COLOR = 16; const COLOR = 16;
const FILE = 17; const FILE = 17;
const REFERENCE = 18; 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;
}
}
} }

View File

@ -22,4 +22,14 @@ class CompletionList
* @var CompletionItem[] * @var CompletionItem[]
*/ */
public $items; 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;
}
} }

View File

@ -11,14 +11,14 @@ class CompletionOptions
* The server provides support to resolve additional information for a completion * The server provides support to resolve additional information for a completion
* item. * item.
* *
* @var bool * @var bool|null
*/ */
public $resolveProvider; public $resolveProvider;
/** /**
* The characters that trigger completion automatically. * The characters that trigger completion automatically.
* *
* @var string|null * @var string[]|null
*/ */
public $triggerCharacters; public $triggerCharacters;
} }

View File

@ -49,4 +49,17 @@ class Position
return $this->character - $position->character; 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;
}
} }

View File

@ -88,7 +88,7 @@ class SymbolInformation
} }
$symbol->location = Location::fromNode($node); $symbol->location = Location::fromNode($node);
if ($fqn !== null) { if ($fqn !== null) {
$parts = preg_split('/(::|\\\\)/', $fqn); $parts = preg_split('/(::|->|\\\\)/', $fqn);
array_pop($parts); array_pop($parts);
$symbol->containerName = implode('\\', $parts); $symbol->containerName = implode('\\', $parts);
} }

View File

@ -8,6 +8,8 @@ use Sabre\Event\EmitterInterface;
/** /**
* Must emit a "message" event with a Protocol\Message object as parameter * Must emit a "message" event with a Protocol\Message object as parameter
* when a message comes in * when a message comes in
*
* Must emit a "close" event when the stream closes
*/ */
interface ProtocolReader extends EmitterInterface interface ProtocolReader extends EmitterInterface
{ {

View File

@ -25,7 +25,17 @@ class ProtocolStreamReader extends Emitter implements ProtocolReader
{ {
$this->input = $input; $this->input = $input;
$this->on('close', function () {
Loop\removeReadStream($this->input);
});
Loop\addReadStream($this->input, function () { 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 !== '') { while (($c = fgetc($this->input)) !== false && $c !== '') {
$this->buffer .= $c; $this->buffer .= $c;
switch ($this->parsingMode) { switch ($this->parsingMode) {

View File

@ -3,7 +3,7 @@ declare(strict_types = 1);
namespace LanguageServer\Server; namespace LanguageServer\Server;
use LanguageServer\{LanguageClient, Project, PhpDocument, DefinitionResolver}; use LanguageServer\{LanguageClient, Project, PhpDocument, DefinitionResolver, CompletionProvider};
use PhpParser\PrettyPrinter\Standard as PrettyPrinter; use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use PhpParser\Node; use PhpParser\Node;
use LanguageServer\Protocol\{ use LanguageServer\Protocol\{
@ -18,7 +18,10 @@ use LanguageServer\Protocol\{
SymbolInformation, SymbolInformation,
ReferenceContext, ReferenceContext,
Hover, Hover,
MarkedString MarkedString,
SymbolKind,
CompletionItem,
CompletionItemKind
}; };
use Sabre\Event\Promise; use Sabre\Event\Promise;
use function Sabre\Event\coroutine; use function Sabre\Event\coroutine;
@ -50,12 +53,18 @@ class TextDocument
*/ */
private $definitionResolver; private $definitionResolver;
/**
* @var CompletionProvider
*/
private $completionProvider;
public function __construct(Project $project, LanguageClient $client) public function __construct(Project $project, LanguageClient $client)
{ {
$this->project = $project; $this->project = $project;
$this->client = $client; $this->client = $client;
$this->prettyPrinter = new PrettyPrinter(); $this->prettyPrinter = new PrettyPrinter();
$this->definitionResolver = new DefinitionResolver($project); $this->definitionResolver = new DefinitionResolver($project);
$this->completionProvider = new CompletionProvider($this->definitionResolver, $project);
} }
/** /**
@ -210,4 +219,26 @@ class TextDocument
return new Hover($contents, $range); 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 <CompletionItem[]|CompletionList>
*/
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);
});
}
} }

View File

@ -95,3 +95,25 @@ function getClosestNode(Node $node, string $type)
} }
} }
} }
/**
* Returns the part of $b that is not overlapped by $a
* Example:
*
* stripStringOverlap('whatever<?', '<?php') === 'php'
*
* @param string $a
* @param string $b
* @return string
*/
function stripStringOverlap(string $a, string $b): string
{
$aLen = strlen($a);
$bLen = strlen($b);
for ($i = 1; $i <= $bLen; $i++) {
if (substr($b, 0, $i) === substr($a, $aLen - $i)) {
return substr($b, $i);
}
}
return $b;
}

View File

@ -36,7 +36,10 @@ class LanguageServerTest extends TestCase
'textDocumentSync' => TextDocumentSyncKind::FULL, 'textDocumentSync' => TextDocumentSyncKind::FULL,
'documentSymbolProvider' => true, 'documentSymbolProvider' => true,
'hoverProvider' => true, 'hoverProvider' => true,
'completionProvider' => null, 'completionProvider' => (object)[
'resolveProvider' => false,
'triggerCharacters' => ['$', '>']
],
'signatureHelpProvider' => null, 'signatureHelpProvider' => null,
'definitionProvider' => true, 'definitionProvider' => true,
'referencesProvider' => true, 'referencesProvider' => true,
@ -61,7 +64,7 @@ class LanguageServerTest extends TestCase
if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) { if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) {
if ($msg->body->params->type === MessageType::ERROR) { if ($msg->body->params->type === MessageType::ERROR) {
$promise->reject(new Exception($msg->body->params->message)); $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(); $promise->fulfill();
} }
} }
@ -106,7 +109,7 @@ class LanguageServerTest extends TestCase
if ($promise->state === Promise::PENDING) { if ($promise->state === Promise::PENDING) {
$promise->reject(new Exception($msg->body->params->message)); $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 // Indexing finished
$promise->fulfill(); $promise->fulfill();
} }

View File

@ -30,13 +30,14 @@ class DefinitionCollectorTest extends TestCase
$traverser->traverse($stmts); $traverser->traverse($stmts);
$defNodes = $definitionCollector->nodes; $defNodes = $definitionCollector->nodes;
$this->assertEquals([ $this->assertEquals([
'TestNamespace',
'TestNamespace\\TEST_CONST', 'TestNamespace\\TEST_CONST',
'TestNamespace\\TestClass', 'TestNamespace\\TestClass',
'TestNamespace\\TestClass::TEST_CLASS_CONST', 'TestNamespace\\TestClass::TEST_CLASS_CONST',
'TestNamespace\\TestClass::staticTestProperty', 'TestNamespace\\TestClass::$staticTestProperty',
'TestNamespace\\TestClass::testProperty', 'TestNamespace\\TestClass->testProperty',
'TestNamespace\\TestClass::staticTestMethod()', 'TestNamespace\\TestClass::staticTestMethod()',
'TestNamespace\\TestClass::testMethod()', 'TestNamespace\\TestClass->testMethod()',
'TestNamespace\\TestTrait', 'TestNamespace\\TestTrait',
'TestNamespace\\TestInterface', 'TestNamespace\\TestInterface',
'TestNamespace\\test_function()' 'TestNamespace\\test_function()'
@ -44,10 +45,10 @@ class DefinitionCollectorTest extends TestCase
$this->assertInstanceOf(Node\Const_::class, $defNodes['TestNamespace\\TEST_CONST']); $this->assertInstanceOf(Node\Const_::class, $defNodes['TestNamespace\\TEST_CONST']);
$this->assertInstanceOf(Node\Stmt\Class_::class, $defNodes['TestNamespace\\TestClass']); $this->assertInstanceOf(Node\Stmt\Class_::class, $defNodes['TestNamespace\\TestClass']);
$this->assertInstanceOf(Node\Const_::class, $defNodes['TestNamespace\\TestClass::TEST_CLASS_CONST']); $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::$staticTestProperty']);
$this->assertInstanceOf(Node\Stmt\PropertyProperty::class, $defNodes['TestNamespace\\TestClass::testProperty']); $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::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\Trait_::class, $defNodes['TestNamespace\\TestTrait']);
$this->assertInstanceOf(Node\Stmt\Interface_::class, $defNodes['TestNamespace\\TestInterface']); $this->assertInstanceOf(Node\Stmt\Interface_::class, $defNodes['TestNamespace\\TestInterface']);
$this->assertInstanceOf(Node\Stmt\Function_::class, $defNodes['TestNamespace\\test_function()']); $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)); $stmts = $parser->parse(file_get_contents($uri));
$traverser->traverse($stmts); $traverser->traverse($stmts);
$defNodes = $definitionCollector->nodes; $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()']); $this->assertInstanceOf(Node\Stmt\Function_::class, $defNodes['TestNamespace\\whatever()']);
} }
} }

View File

@ -54,13 +54,11 @@ abstract class ServerTestCase extends TestCase
$referencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/references.php')); $referencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/references.php'));
$useUri = pathToUri(realpath(__DIR__ . '/../../fixtures/use.php')); $useUri = pathToUri(realpath(__DIR__ . '/../../fixtures/use.php'));
Promise\all([ $this->project->loadDocument($symbolsUri)->wait();
$this->project->loadDocument($symbolsUri), $this->project->loadDocument($referencesUri)->wait();
$this->project->loadDocument($referencesUri), $this->project->loadDocument($globalSymbolsUri)->wait();
$this->project->loadDocument($globalSymbolsUri), $this->project->loadDocument($globalReferencesUri)->wait();
$this->project->loadDocument($globalReferencesUri), $this->project->loadDocument($useUri)->wait();
$this->project->loadDocument($useUri)
])->wait();
// @codingStandardsIgnoreStart // @codingStandardsIgnoreStart
$this->definitionLocations = [ $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))), 'whatever()' => new Location($globalReferencesUri, new Range(new Position(21, 0), new Position(23, 1))),
// Namespaced // 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\\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\\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))), 'TestNamespace\\TestTrait' => new Location($symbolsUri, new Range(new Position(63, 0), new Position(66, 1))),

View File

@ -0,0 +1,383 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Tests\Server\TextDocument;
use PHPUnit\Framework\TestCase;
use LanguageServer\Tests\MockProtocolStream;
use LanguageServer\{Server, LanguageClient, Project, CompletionProvider};
use LanguageServer\Protocol\{
TextDocumentIdentifier,
TextEdit,
Range,
Position,
ClientCapabilities,
CompletionList,
CompletionItem,
CompletionItemKind
};
use function LanguageServer\pathToUri;
class CompletionTest extends TestCase
{
/**
* @var Server\TextDocument
*/
private $textDocument;
/**
* @var Project
*/
private $project;
public function setUp()
{
$client = new LanguageClient(new MockProtocolStream, new MockProtocolStream);
$this->project = new Project($client, new ClientCapabilities);
$this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php'))->wait();
$this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/symbols.php'))->wait();
$this->textDocument = new Server\TextDocument($this->project, $client);
}
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(
'<?php',
CompletionItemKind::KEYWORD,
null,
null,
null,
null,
null,
new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), '<?php')
)
], true), $items);
}
public function testHtmlWithPrefix()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php');
$this->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(
'<?php',
CompletionItemKind::KEYWORD,
null,
null,
null,
null,
null,
new TextEdit(new Range(new Position(0, 1), new Position(0, 1)), '?php')
)
], true), $items);
}
public function testNamespace()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/namespace.php');
$this->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);
}
}

View File

@ -18,6 +18,7 @@ class DocumentSymbolTest extends ServerTestCase
$result = $this->textDocument->documentSymbol(new TextDocumentIdentifier($uri))->wait(); $result = $this->textDocument->documentSymbol(new TextDocumentIdentifier($uri))->wait();
// @codingStandardsIgnoreStart // @codingStandardsIgnoreStart
$this->assertEquals([ $this->assertEquals([
new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('TestNamespace'), ''),
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'),
new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'),
new SymbolInformation('TEST_CLASS_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TestClass::TEST_CLASS_CONST'), 'TestNamespace\\TestClass'), new SymbolInformation('TEST_CLASS_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TestClass::TEST_CLASS_CONST'), 'TestNamespace\\TestClass'),

View File

@ -6,7 +6,17 @@ namespace LanguageServer\Tests\Server\Workspace;
use LanguageServer\Tests\MockProtocolStream; use LanguageServer\Tests\MockProtocolStream;
use LanguageServer\Tests\Server\ServerTestCase; use LanguageServer\Tests\Server\ServerTestCase;
use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument}; 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 AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody};
use function LanguageServer\pathToUri; use function LanguageServer\pathToUri;
@ -16,8 +26,10 @@ class SymbolTest extends ServerTestCase
{ {
// Request symbols // Request symbols
$result = $this->workspace->symbol(''); $result = $this->workspace->symbol('');
$referencesUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/references.php'));
// @codingStandardsIgnoreStart // @codingStandardsIgnoreStart
$this->assertEquals([ $this->assertEquals([
new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''),
// Namespaced // Namespaced
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'),
new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'),
@ -41,7 +53,9 @@ class SymbolTest extends ServerTestCase
new SymbolInformation('TestTrait', SymbolKind::CLASS_, $this->getDefinitionLocation('TestTrait'), ''), new SymbolInformation('TestTrait', SymbolKind::CLASS_, $this->getDefinitionLocation('TestTrait'), ''),
new SymbolInformation('TestInterface', SymbolKind::INTERFACE, $this->getDefinitionLocation('TestInterface'), ''), new SymbolInformation('TestInterface', SymbolKind::INTERFACE, $this->getDefinitionLocation('TestInterface'), ''),
new SymbolInformation('test_function', SymbolKind::FUNCTION, $this->getDefinitionLocation('test_function()'), ''), 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); ], $result);
// @codingStandardsIgnoreEnd // @codingStandardsIgnoreEnd
} }

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Tests\Utils;
use PHPUnit\Framework\TestCase;
use function LanguageServer\stripStringOverlap;
class StripStringOverlapTest extends TestCase
{
public function testNoCharOverlaps()
{
$this->assertEquals('<?php', stripStringOverlap('bla', '<?php'));
}
public function test1CharOverlaps()
{
$this->assertEquals('?php', stripStringOverlap('bla<', '<?php'));
}
public function test2CharsOverlap()
{
$this->assertEquals('php', stripStringOverlap('bla<?', '<?php'));
}
public function testEverythingOverlaps()
{
$this->assertEquals('', stripStringOverlap('bla<?php', '<?php'));
}
public function testEmptyA()
{
$this->assertEquals('<?php', stripStringOverlap('', '<?php'));
}
public function testEmptyB()
{
$this->assertEquals('', stripStringOverlap('bla', ''));
}
public function testBothEmpty()
{
$this->assertEquals('', stripStringOverlap('', ''));
}
}