Skip to content

Commit 9eea26d

Browse files
phil-nelsonfelixfbecker
authored andcommitted
feat: foreach completion (#551)
1 parent f46fccd commit 9eea26d

File tree

7 files changed

+227
-3
lines changed

7 files changed

+227
-3
lines changed

fixtures/completion/foreach.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Foo;
4+
5+
class Bar {
6+
public $foo;
7+
8+
/** @return Bar[] */
9+
public function test() { }
10+
}
11+
12+
$bar = new Bar();
13+
$bars = $bar->test();
14+
$array1 = [new Bar(), new \stdClass()];
15+
$array2 = ['foo' => $bar, $bar];
16+
$array3 = ['foo' => $bar, 'baz' => $bar];
17+
18+
foreach ($bars as $value) {
19+
$v
20+
$value->
21+
}
22+
23+
foreach ($array1 as $key => $value) {
24+
$
25+
}
26+
27+
foreach ($array2 as $key => $value) {
28+
$
29+
}
30+
31+
foreach ($array3 as $key => $value) {
32+
$
33+
}
34+
35+
foreach ($bar->test() as $value) {
36+
$
37+
}

src/CompletionProvider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,14 @@ private function findVariableDefinitionsInNode(Node $node, string $namePrefix =
486486

487487
if ($this->isAssignmentToVariableWithPrefix($node, $namePrefix)) {
488488
$vars[] = $node->leftOperand;
489+
} elseif ($node instanceof Node\ForeachKey || $node instanceof Node\ForeachValue) {
490+
foreach ($node->getDescendantNodes() as $descendantNode) {
491+
if ($descendantNode instanceof Node\Expression\Variable
492+
&& ($namePrefix === '' || strpos($descendantNode->getName(), $namePrefix) !== false)
493+
) {
494+
$vars[] = $descendantNode;
495+
}
496+
}
489497
} else {
490498
// Get all descendent variables, then filter to ones that start with $namePrefix.
491499
// Avoiding closure usage in tight loop

src/DefinitionResolver.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,20 @@ public function resolveVariableToNode($var)
568568
}
569569
break;
570570
}
571+
572+
// If we get to a ForeachStatement, check the keys and values
573+
if ($n instanceof Node\Statement\ForeachStatement) {
574+
if ($n->foreachKey && $n->foreachKey->expression->getName() === $name) {
575+
return $n->foreachKey;
576+
}
577+
if ($n->foreachValue
578+
&& $n->foreachValue->expression instanceof Node\Expression\Variable
579+
&& $n->foreachValue->expression->getName() === $name
580+
) {
581+
return $n->foreachValue;
582+
}
583+
}
584+
571585
// Check each previous sibling node for a variable assignment to that variable
572586
while (($prevSibling = $n->getPreviousSibling()) !== null && $n = $prevSibling) {
573587
if ($n instanceof Node\Statement\ExpressionStatement) {
@@ -619,6 +633,9 @@ public function resolveExpressionNodeToType($expr)
619633
if ($defNode instanceof Node\Expression\AssignmentExpression || $defNode instanceof Node\UseVariableName) {
620634
return $this->resolveExpressionNodeToType($defNode);
621635
}
636+
if ($defNode instanceof Node\ForeachKey || $defNode instanceof Node\ForeachValue) {
637+
return $this->getTypeFromNode($defNode);
638+
}
622639
if ($defNode instanceof Node\Parameter) {
623640
return $this->getTypeFromNode($defNode);
624641
}
@@ -900,7 +917,7 @@ public function resolveExpressionNodeToType($expr)
900917
$keyTypes[] = $item->elementKey ? $this->resolveExpressionNodeToType($item->elementKey) : new Types\Integer;
901918
}
902919
}
903-
$valueTypes = array_unique($keyTypes);
920+
$valueTypes = array_unique($valueTypes);
904921
$keyTypes = array_unique($keyTypes);
905922
if (empty($valueTypes)) {
906923
$valueType = null;
@@ -1080,6 +1097,27 @@ public function getTypeFromNode($node)
10801097
return new Types\Mixed_;
10811098
}
10821099

1100+
// FOREACH KEY/VARIABLE
1101+
if ($node instanceof Node\ForeachKey || $node->parent instanceof Node\ForeachKey) {
1102+
$foreach = $node->getFirstAncestor(Node\Statement\ForeachStatement::class);
1103+
$collectionType = $this->resolveExpressionNodeToType($foreach->forEachCollectionName);
1104+
if ($collectionType instanceof Types\Array_) {
1105+
return $collectionType->getKeyType();
1106+
}
1107+
return new Types\Mixed_();
1108+
}
1109+
1110+
// FOREACH VALUE/VARIABLE
1111+
if ($node instanceof Node\ForeachValue
1112+
|| ($node instanceof Node\Expression\Variable && $node->parent instanceof Node\ForeachValue)
1113+
) {
1114+
$foreach = $node->getFirstAncestor(Node\Statement\ForeachStatement::class);
1115+
$collectionType = $this->resolveExpressionNodeToType($foreach->forEachCollectionName);
1116+
if ($collectionType instanceof Types\Array_) {
1117+
return $collectionType->getValueType();
1118+
}
1119+
}
1120+
10831121
// PROPERTIES, CONSTS, CLASS CONSTS, ASSIGNMENT EXPRESSIONS
10841122
// Get the documented type the assignment resolves to.
10851123
if (

src/Server/TextDocument.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ public function hover(TextDocumentIdentifier $textDocument, Position $position):
337337
if ($def === null) {
338338
return new Hover([], $range);
339339
}
340+
$contents = [];
340341
if ($def->declarationLine) {
341342
$contents[] = new MarkedString('php', "<?php\n" . $def->declarationLine);
342343
}

tests/Server/TextDocument/CompletionTest.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,146 @@ public function testBarePhp()
554554
], true), $items);
555555
}
556556

557+
/**
558+
* @dataProvider foreachProvider
559+
*/
560+
public function testForeach(Position $position, array $expectedItems)
561+
{
562+
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/foreach.php');
563+
$this->loader->open($completionUri, file_get_contents($completionUri));
564+
$items = $this->textDocument->completion(
565+
new TextDocumentIdentifier($completionUri),
566+
$position
567+
)->wait();
568+
$this->assertCompletionsListSubset(new CompletionList($expectedItems, true), $items);
569+
}
570+
571+
public function foreachProvider(): array
572+
{
573+
return [
574+
'foreach value' => [
575+
new Position(18, 6),
576+
[
577+
new CompletionItem(
578+
'$value',
579+
CompletionItemKind::VARIABLE,
580+
'\\Foo\\Bar',
581+
null,
582+
null,
583+
null,
584+
null,
585+
new TextEdit(new Range(new Position(18, 6), new Position(18, 6)), 'alue')
586+
),
587+
]
588+
],
589+
'foreach value resolved' => [
590+
new Position(19, 12),
591+
[
592+
new CompletionItem(
593+
'foo',
594+
CompletionItemKind::PROPERTY,
595+
'mixed'
596+
),
597+
new CompletionItem(
598+
'test',
599+
CompletionItemKind::METHOD,
600+
'\\Foo\\Bar[]'
601+
),
602+
]
603+
],
604+
'array creation with multiple objects' => [
605+
new Position(23, 5),
606+
[
607+
new CompletionItem(
608+
'$value',
609+
CompletionItemKind::VARIABLE,
610+
'\\Foo\\Bar|\\stdClass',
611+
null,
612+
null,
613+
null,
614+
null,
615+
new TextEdit(new Range(new Position(23, 5), new Position(23, 5)), 'value')
616+
),
617+
new CompletionItem(
618+
'$key',
619+
CompletionItemKind::VARIABLE,
620+
'int',
621+
null,
622+
null,
623+
null,
624+
null,
625+
new TextEdit(new Range(new Position(23, 5), new Position(23, 5)), 'key')
626+
),
627+
]
628+
],
629+
'array creation with string/int keys and object values' => [
630+
new Position(27, 5),
631+
[
632+
new CompletionItem(
633+
'$value',
634+
CompletionItemKind::VARIABLE,
635+
'\\Foo\\Bar',
636+
null,
637+
null,
638+
null,
639+
null,
640+
new TextEdit(new Range(new Position(27, 5), new Position(27, 5)), 'value')
641+
),
642+
new CompletionItem(
643+
'$key',
644+
CompletionItemKind::VARIABLE,
645+
'string|int',
646+
null,
647+
null,
648+
null,
649+
null,
650+
new TextEdit(new Range(new Position(27, 5), new Position(27, 5)), 'key')
651+
),
652+
]
653+
],
654+
'array creation with only string keys' => [
655+
new Position(31, 5),
656+
[
657+
new CompletionItem(
658+
'$value',
659+
CompletionItemKind::VARIABLE,
660+
'\\Foo\\Bar',
661+
null,
662+
null,
663+
null,
664+
null,
665+
new TextEdit(new Range(new Position(31, 5), new Position(31, 5)), 'value')
666+
),
667+
new CompletionItem(
668+
'$key',
669+
CompletionItemKind::VARIABLE,
670+
'string',
671+
null,
672+
null,
673+
null,
674+
null,
675+
new TextEdit(new Range(new Position(31, 5), new Position(31, 5)), 'key')
676+
),
677+
]
678+
],
679+
'foreach function call' => [
680+
new Position(35, 5),
681+
[
682+
new CompletionItem(
683+
'$value',
684+
CompletionItemKind::VARIABLE,
685+
'\\Foo\\Bar',
686+
null,
687+
null,
688+
null,
689+
null,
690+
new TextEdit(new Range(new Position(35, 5), new Position(35, 5)), 'value')
691+
),
692+
]
693+
],
694+
];
695+
}
696+
557697
public function testMethodReturnType()
558698
{
559699
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/method_return_type.php');

tests/Validation/cases/arrayValueShouldBeBoolean.php.expected.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
},
3737
"containerName": "A"
3838
},
39-
"type__tostring": "string[]",
39+
"type__tostring": "bool[]",
4040
"type": {},
4141
"declarationLine": "protected $foo;",
4242
"documentation": null,

tests/Validation/cases/magicConsts.php.expected.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
},
4141
"containerName": "A"
4242
},
43-
"type__tostring": "\\__CLASS__[]",
43+
"type__tostring": "bool[]",
4444
"type": {},
4545
"declarationLine": "private static $deprecationsTriggered;",
4646
"documentation": null,

0 commit comments

Comments
 (0)