From 5f1cea044a6ff4287f28aa29bec3a484b4771f47 Mon Sep 17 00:00:00 2001 From: przepompownia Date: Wed, 3 Dec 2025 00:43:55 +0100 Subject: [PATCH] KeywordCompletor: complete statement keywords --- .../TolerantParser/CompletionContext.php | 114 +++++++- .../WorseReflection/KeywordCompletor.php | 72 ++++- .../WorseReflection/KeywordCompletorTest.php | 164 ++++++++++-- .../TolerantParser/CompletionContextTest.php | 251 +++++++++++++++++- 4 files changed, 574 insertions(+), 27 deletions(-) diff --git a/lib/Completion/Bridge/TolerantParser/CompletionContext.php b/lib/Completion/Bridge/TolerantParser/CompletionContext.php index 5ceb7fc2b..ed17c15f7 100644 --- a/lib/Completion/Bridge/TolerantParser/CompletionContext.php +++ b/lib/Completion/Bridge/TolerantParser/CompletionContext.php @@ -8,15 +8,21 @@ use Microsoft\PhpParser\Node\ArrayElement; use Microsoft\PhpParser\Node\Attribute; use Microsoft\PhpParser\Node\AttributeGroup; +use Microsoft\PhpParser\Node\CaseStatementNode; +use Microsoft\PhpParser\Node\CatchClause; use Microsoft\PhpParser\Node\ClassBaseClause; use Microsoft\PhpParser\Node\ClassInterfaceClause; use Microsoft\PhpParser\Node\ClassMembersNode; use Microsoft\PhpParser\Node\ConstElement; +use Microsoft\PhpParser\Node\DelimitedList\ExpressionList; use Microsoft\PhpParser\Node\DelimitedList\MatchArmConditionList; use Microsoft\PhpParser\Node\DelimitedList\QualifiedNameList; use Microsoft\PhpParser\Node\Expression; use Microsoft\PhpParser\Node\Expression\AnonymousFunctionCreationExpression; use Microsoft\PhpParser\Node\Expression\ArgumentExpression; +use Microsoft\PhpParser\Node\Expression\CallExpression; +use Microsoft\PhpParser\Node\Expression\MemberAccessExpression; +use Microsoft\PhpParser\Node\Expression\ScopedPropertyAccessExpression; use Microsoft\PhpParser\Node\Expression\BinaryExpression; use Microsoft\PhpParser\Node\Expression\Variable; use Microsoft\PhpParser\Node\InterfaceBaseClause; @@ -29,13 +35,20 @@ use Microsoft\PhpParser\Node\StatementNode; use Microsoft\PhpParser\Node\Statement\ClassDeclaration; use Microsoft\PhpParser\Node\Statement\CompoundStatementNode; +use Microsoft\PhpParser\Node\Statement\DoStatement; +use Microsoft\PhpParser\Node\Statement\EchoStatement; use Microsoft\PhpParser\Node\Statement\EnumDeclaration; +use Microsoft\PhpParser\Node\Statement\ForStatement; +use Microsoft\PhpParser\Node\Statement\ForeachStatement; use Microsoft\PhpParser\Node\Statement\ExpressionStatement; use Microsoft\PhpParser\Node\Statement\InlineHtml; use Microsoft\PhpParser\Node\Statement\IfStatementNode; use Microsoft\PhpParser\Node\Statement\WhileStatement; use Microsoft\PhpParser\Node\Statement\InterfaceDeclaration; +use Microsoft\PhpParser\Node\Statement\NamespaceUseDeclaration; +use Microsoft\PhpParser\Node\Statement\SwitchStatementNode; use Microsoft\PhpParser\Node\Statement\TraitDeclaration; +use Microsoft\PhpParser\Node\StringLiteral; use Microsoft\PhpParser\Node\TraitUseClause; use Microsoft\PhpParser\TokenKind; use Phpactor\TextDocument\ByteOffset; @@ -62,7 +75,20 @@ public static function expression(?Node $node): bool return false; } - if ($parent instanceof ArgumentExpression) { + if ( + $node instanceof Variable + || $node instanceof ExpressionStatement + || $node instanceof MemberAccessExpression + || $node instanceof ScopedPropertyAccessExpression + || $node instanceof StringLiteral + ) { + return false; + } + + if ( + $node instanceof CallExpression + || $parent instanceof ArgumentExpression + ) { return true; } @@ -306,6 +332,92 @@ public static function methodName(Node $node): bool return $node->parent->openParen instanceof MissingToken; } + public static function statement(Node $node, ByteOffset $offset): bool + { + if ($node instanceof NamespaceUseDeclaration) { + return false; + } + + if ($node instanceof CaseStatementNode) { + return true; + } + + if ($node instanceof CompoundStatementNode) { + if ($node->parent instanceof MethodDeclaration && $node->openBrace instanceof MissingToken) { + return false; + } + + $lastStmt = \end($node->statements); + if (false === $lastStmt || $lastStmt->getEndPosition() > $offset->toInt()) { + return true; + } + + return !$lastStmt instanceof EchoStatement; + } + + if ($node instanceof Expression) { + return false; + } + + if ($node instanceof SwitchStatementNode) { + if ([] === $node->caseStatements) { + return false; + } + + return $offset->toInt() > $node->caseStatements[0]->getStartPosition(); + } + + if ( + $node->parent && $node->parent->getEndPosition() === $offset->toInt() + && ( + $node->parent instanceof WhileStatement + || $node->parent instanceof DoStatement + || $node->parent instanceof IfStatementNode + || $node->parent instanceof CatchClause + || $node->parent instanceof ForeachStatement + || $node->parent instanceof SwitchStatementNode + ) && $node->parent->openParen instanceof MissingToken + ) { + return true; + } + + if ( + $node instanceof WhileStatement + || $node instanceof IfStatementNode + || $node instanceof DoStatement + || $node instanceof CatchClause + || $node instanceof ForStatement + || $node instanceof ForeachStatement + || $node instanceof EchoStatement + || $node->parent instanceof ExpressionList + || $node->parent instanceof WhileStatement + || $node->parent instanceof DoStatement + || $node->parent instanceof IfStatementNode + || $node->parent instanceof CatchClause + || $node->parent instanceof ForeachStatement + || $node->parent instanceof SwitchStatementNode + ) { + return false; + } + + return $node->parent instanceof CaseStatementNode + || $node->parent instanceof SourceFileNode + || $node->parent instanceof CompoundStatementNode + || $node->parent?->parent instanceof CaseStatementNode + || $node->parent?->parent instanceof CompoundStatementNode; + } + + public static function loopOrSwitch(Node $node): bool + { + return $node->getFirstAncestor( + DoStatement::class, + ForStatement::class, + ForeachStatement::class, + SwitchStatementNode::class, + WhileStatement::class, + ) instanceof Node; + } + public static function declaration(Node $node, ByteOffset $offset): bool { if (!$node->parent) { diff --git a/lib/Completion/Bridge/TolerantParser/WorseReflection/KeywordCompletor.php b/lib/Completion/Bridge/TolerantParser/WorseReflection/KeywordCompletor.php index e4c4a9676..e022a03e9 100644 --- a/lib/Completion/Bridge/TolerantParser/WorseReflection/KeywordCompletor.php +++ b/lib/Completion/Bridge/TolerantParser/WorseReflection/KeywordCompletor.php @@ -7,6 +7,7 @@ use Generator; use Microsoft\PhpParser\Node; use Microsoft\PhpParser\Node\MethodDeclaration; +use Microsoft\PhpParser\Node\StatementNode; use Phpactor\Completion\Bridge\TolerantParser\CompletionContext; use Phpactor\Completion\Bridge\TolerantParser\TolerantCompletor; use Phpactor\Completion\Core\Suggestion; @@ -15,6 +16,10 @@ class KeywordCompletor implements TolerantCompletor { + private const EXPRESSIONS = [ + 'match' => " (\$1) {\$0\n}", + 'throw' => ' $1', + ]; private const MAGIC_METHODS = [ '__construct' => "(\$1)\n{\$0\n}", '__call' => "(string \\\$\${1:name}, array \\\$\${2:arguments}): \${3:mixed}\n{\$0\n}", @@ -34,11 +39,26 @@ class KeywordCompletor implements TolerantCompletor '__unset' => "(string \\\$\${1:name}): void\n{\$0\n}", '__wakeup' => "(): void\n{\$0\n}", ]; + private const STATEMENTS = [ + 'break' => '$1;$0', + 'continue' => '$1;$0', + 'do' => " {\n\t\$0\n} while (\$2);", + 'echo' => ' $1;$0', + 'for' => " (\${1:expr1}, \${2:expr2}, \${3:expr3}) {\n\t\$0\n}", + 'foreach' => " (\\\$\${1:expr} as \\\$\${2:key} => \\\$\${3:value}) {\$0\n}", + 'if' => " (\$1) {\$0\n}", + 'return' => ' $1;$0', + 'switch' => " (\\\$\${1:expr}) {\n\tcase \${2:expr}:\n\t\t\$0\n}", + 'throw' => ' $1;$0', + 'try' => " {\$3\n} catch (\${1:Exception} \\\$\${2:error}) {\$4\n}", + 'while' => " (\$1) {\$0\n}", + 'yield' => ' $1;$0', + ]; public function complete(Node $node, TextDocument $source, ByteOffset $offset): Generator { if (CompletionContext::promotedPropertyVisibility($node)) { - yield from $this->keywords(['private ', 'public ', 'protected ', ]); + yield from $this->keywords(['private ', 'public ', 'protected ']); return true; } if (CompletionContext::classClause($node, $offset)) { @@ -64,7 +84,21 @@ public function complete(Node $node, TextDocument $source, ByteOffset $offset): return true; } - if (!$node instanceof MethodDeclaration && CompletionContext::classMembersBody($node->parent)) { + if (CompletionContext::statement($node, $offset)) { + yield from $this->statements(CompletionContext::loopOrSwitch($node)); + return true; + } + + if (CompletionContext::expression($node)) { + yield from $this->expressions(); + return true; + } + + if ( + !$node instanceof MethodDeclaration + && CompletionContext::classMembersBody($node->parent) + && !$node->parent instanceof StatementNode + ) { yield from $this->keywords([ 'function ', 'const ', @@ -80,6 +114,20 @@ public function complete(Node $node, TextDocument $source, ByteOffset $offset): return true; } + /** + * @return Generator + */ + private function expressions(): Generator + { + foreach (self::EXPRESSIONS as $name => $snippet) { + yield Suggestion::createWithOptions($name . ' ', [ + 'type' => Suggestion::TYPE_KEYWORD, + 'priority' => -255, + 'snippet' => $name . $snippet, + ]); + } + } + /** * @return Generator */ @@ -97,6 +145,24 @@ private function methods(): Generator } } + /** + * @return Generator + */ + private function statements(bool $loop): Generator + { + foreach (self::STATEMENTS as $name => $snippet) { + if (!$loop && in_array($name, ['continue', 'break'], true)) { + continue; + } + + yield Suggestion::createWithOptions($name . ' ', [ + 'type' => Suggestion::TYPE_KEYWORD, + 'priority' => -255, + 'snippet' => $name . $snippet, + ]); + } + } + /** * @return Generator * @param string[] $keywords @@ -106,7 +172,7 @@ private function keywords(array $keywords): Generator foreach ($keywords as $keyword) { yield Suggestion::createWithOptions($keyword, [ 'type' => Suggestion::TYPE_KEYWORD, - 'priority' => 1, + 'priority' => -255, ]); } } diff --git a/lib/Completion/Tests/Integration/Bridge/TolerantParser/WorseReflection/KeywordCompletorTest.php b/lib/Completion/Tests/Integration/Bridge/TolerantParser/WorseReflection/KeywordCompletorTest.php index c840eaf9b..ee26181ff 100644 --- a/lib/Completion/Tests/Integration/Bridge/TolerantParser/WorseReflection/KeywordCompletorTest.php +++ b/lib/Completion/Tests/Integration/Bridge/TolerantParser/WorseReflection/KeywordCompletorTest.php @@ -23,29 +23,29 @@ public function testComplete(string $source, array $expected): void /** * @return Generator[]}> */ - public function provideComplete(): Generator + public static function provideComplete(): Generator { yield 'member keywords' => [ '', - $this->expect(['private ', 'protected ', 'public ']), + self::expect(['private ', 'protected ', 'public ']), ]; yield 'member keyword postfix' => [ '', - $this->expect(['const ', 'function ']), + self::expect(['const ', 'function ']), ]; yield 'member keyword postfix 2' => [ '', - $this->expect(['const ', 'function ']), + self::expect(['const ', 'function ']), ]; yield '__construct' => [ '', - [...$this->expectMagicMethods()], + [...self::expectMagicMethods()], ]; yield '__construct 2' => [ ' }', - [...$this->expectMagicMethods()], + [...self::expectMagicMethods()], ]; yield 'no magic methods here' => [ @@ -55,41 +55,125 @@ public function provideComplete(): Generator yield 'class implements 1' => [ '', - $this->expect(['extends ', 'implements ']), + self::expect(['extends ', 'implements ']), ]; yield 'class implements 2' => [ '', - $this->expect(['extends ', 'implements ']), + self::expect(['extends ', 'implements ']), ]; yield 'class keyword' => [ '', - $this->expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), + self::expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), ]; yield 'class keyword 2' => [ '', - $this->expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), + self::expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), ]; yield 'class keyword 3' => [ '', - $this->expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), + self::expect(['class ', 'enum ', 'function ', 'interface ', 'trait ']), + ]; + yield 'method empty body keyword' => [ + ' }}', + [...self::expectStatement(false)], + ]; + yield 'method body keyword' => [ + ' }}', + [...self::expectStatement(false)], + ]; + yield 'method body subnode' => [ + ' } }}', + [...self::expectStatement(false)], + ]; + yield 'root subnode' => [ + '', + [...self::expectStatement(false)], + ]; + yield 'namespace subnode' => [ + '', + [...self::expectStatement(false)], + ]; + yield 'inside try' => [ + ' } catch (\Exception $e) {}', + [...self::expectStatement(false)], + ]; + yield 'inside catch' => [ + ' }', + [...self::expectStatement(false)], + ]; + yield 'inside case 1' => [ + ' }', + [...self::expectStatement(true)], + ]; + yield 'at the end of full name' => [ + ' }}', + [...self::expectStatement(true)] + [...self::expectExpressions()], + ]; + yield 'parent is stmt, inside parens' => [ + ' }}', + [], + ]; + yield 'inside case 2' => [ + ' }', + [...self::expectStatement(true)], + ]; + yield 'inside while condition' => [ + ' }', + [...self::expectStatement(true)], + ]; + yield 'match keyword' => [ + ' }}', + [...self::expectExpressions()], + ]; + yield 'match unexpected' => [ + 'mat<> }}', + [], + ]; + yield 'match unexpected 2' => [ + 'foo(<>) }}', + [...self::expectExpressions()], + ]; + yield 'match unexpected 3' => [ + 'foo(self::<>) }}', + [], + ]; + yield 'match unexpected 4' => [ + ' {}', + [], + ]; + yield 'match unexpected in string' => [ + '', + [], + ]; + yield 'match unexpected 5' => [ + '', + [], + ]; + yield 'inside import' => [ + '', + [], + ]; + yield 'inside comment' => [ + '', + [], ]; yield 'if condition classes' => [ '} }', - $this->expect(['instanceof ']), + self::expect(['instanceof ']), ]; yield 'if condition' => [ '', - $this->expect(['instanceof ']), + self::expect(['instanceof ']), ]; yield 'while with empty expression' => [ '', - $this->expect([]), + self::expect([]), ]; yield 'while condition' => [ '', - $this->expect(['instanceof ']), + self::expect(['instanceof ']), ]; yield 'while condition (without variable)' => [ '', @@ -97,7 +181,7 @@ public function provideComplete(): Generator ]; yield 'while condition (with expression)' => [ 'getParent() i<>', - $this->expect(['instanceof ']), + self::expect(['instanceof ']), ]; } @@ -110,7 +194,7 @@ protected function createTolerantCompletor(TextDocument $source): TolerantComple * @return array> * @param array $array */ - private function expect(array $array): array + private static function expect(array $array): array { return array_map(fn (string $keyword) => [ 'name' => $keyword, @@ -120,7 +204,51 @@ private function expect(array $array): array /** * @return Generator */ - private function expectMagicMethods(): Generator + private static function expectStatement(bool $loop): Generator + { + $statements = [ + 'break' => '$1;$0', + 'continue' => '$1;$0', + 'do' => " {\n\t\$0\n} while (\$2);", + 'echo' => ' $1;$0', + 'for' => " (\${1:expr1}, \${2:expr2}, \${3:expr3}) {\n\t\$0\n}", + 'foreach' => " (\\\$\${1:expr} as \\\$\${2:key} => \\\$\${3:value}) {\$0\n}", + 'if' => " (\$1) {\$0\n}", + 'return' => ' $1;$0', + 'switch' => " (\\\$\${1:expr}) {\n\tcase \${2:expr}:\n\t\t\$0\n}", + 'throw' => ' $1;$0', + 'try' => " {\$3\n} catch (\${1:Exception} \\\$\${2:error}) {\$4\n}", + 'while' => " (\$1) {\$0\n}", + 'yield' => ' $1;$0', + ]; + + foreach ($statements as $name => $snippet) { + if (!$loop && in_array($name, ['continue', 'break'], true)) { + continue; + } + yield ['name' => $name . ' ', 'snippet' => $name . $snippet]; + } + } + + /** + * @return Generator + */ + private static function expectExpressions(): Generator + { + $expressions = [ + 'match' => " (\$1) {\$0\n}", + 'throw' => ' $1', + ]; + + foreach ($expressions as $name => $snippet) { + yield ['name' => $name . ' ', 'snippet' => $name . $snippet]; + } + } + + /** + * @return Generator + */ + private static function expectMagicMethods(): Generator { $methods = [ '__construct' => "(\$1)\n{\$0\n}", diff --git a/lib/Completion/Tests/Unit/Bridge/TolerantParser/CompletionContextTest.php b/lib/Completion/Tests/Unit/Bridge/TolerantParser/CompletionContextTest.php index b67ea979a..37254a421 100644 --- a/lib/Completion/Tests/Unit/Bridge/TolerantParser/CompletionContextTest.php +++ b/lib/Completion/Tests/Unit/Bridge/TolerantParser/CompletionContextTest.php @@ -2,6 +2,7 @@ namespace Phpactor\Completion\Tests\Unit\Bridge\TolerantParser; +use Phpactor\TextDocument\TextDocumentBuilder; use Phpactor\WorseReflection\Bridge\TolerantParser\AstProvider\TolerantAstProvider; use PHPUnit\Framework\Attributes\DataProvider; use Generator; @@ -16,7 +17,7 @@ class CompletionContextTest extends TestCase public function testExpression(string $source, bool $expected): void { [$source, $offset] = ExtractOffset::fromSource($source); - $node = (new TolerantAstProvider())->parseString($source)->getDescendantNodeAtPosition((int)$offset); + $node = (new TolerantAstProvider())->parseString($source)->getDescendantNodeAtPosition($offset); self::assertEquals($expected, CompletionContext::expression($node)); } @@ -69,21 +70,257 @@ public static function provideExpression(): Generator " }", false, ]; + yield 'not between if condition and body' => [ + ' {}', + false, + ]; + yield 'not in variable' => [ + '', + false, + ]; + yield 'not in string literal' => [ + '', + false, + ]; + yield 'not in scoped property access expr' => [ + 'foo(self::<>) }', + false, + ]; yield 'in class method body 1' => [ ' }', - true + true, ]; yield 'in class method body 2' => [ ' } }', true, ]; + yield 'in function call' => [ + 'foo(<>) }', + true, + ]; yield 'in foreach' => [ ' } }', true, ]; } + #[DataProvider('provideStatement')] + public function testStatement(string $source, bool $expected): void + { + [$source, $offset] = ExtractOffset::fromSource($source); + $node = (new TolerantAstProvider())->get(TextDocumentBuilder::fromString($source))->getDescendantNodeAtPosition((int)$offset); + self::assertEquals($expected, CompletionContext::statement($node, ByteOffset::fromInt($offset))); + } + + /** + * @return Generator> + */ + public static function provideStatement(): Generator + { + yield 'root' => [ + '', + true, + ]; + yield 'in namespace' => [ + '', + false, // disabled while no idea how to distinguish this case from that if the cursor is inside comment + ]; + yield 'in comment' => [ + ' + */', + false, + ]; + yield 'statement property' => [ + ' }', + false, + ]; + yield 'statement visibility 1' => [ + ' }', + false, + ]; + yield 'statement visibility 2' => [ + ' }', + false, + ]; + yield 'visibility 3' => [ + ' }', + false, + ]; + yield 'statement method body' => [ + ' } }', + true, + ]; + yield 'nested statement method body' => [ + ' } } }', + true, + ]; + yield 'case statement' => [ + ' } } }', + true, + ]; + yield 'case statement 2' => [ + ' } } }', + true, + ]; + yield 'in while body' => [ + '} } }', + true, + ]; + yield 'in while body descendant' => [ + '} } }', + true, + ]; + + yield 'string literal' => [ + '" }', + false, + ]; + yield 'string literal 2' => [ + '" }', + false, + ]; + yield 'switch condition' => [ + ') {} } }', + false, + ]; + yield 'switch condition 2' => [ + ') {} } }', + false, + ]; + yield 'foreach condition' => [ + ') {} } }', + false, + ]; + yield 'foreach condition 2' => [ + ') {} } }', + false, + ]; + yield 'foreach condition key' => [ + ') {} } }', + false, + ]; + yield 'for condition 1' => [ + ') {} } }', + false, + ]; + yield 'for condition 2' => [ + ') {} } }', + false, + ]; + yield 'for condition 1 descendant 1' => [ + ') {} } }', + false, + ]; + yield 'for condition 2 descendant 1' => [ + ') {} } }', + false, + ]; + yield 'for condition 1 descendant 2' => [ + ') {} } }', + false, + ]; + yield 'catch selector' => [ + ') {} } }', + false, + ]; + yield 'catch selector descendant' => [ + ') {} } }', + false, + ]; + yield 'in do condition' => [ + ') } }', + false, + ]; + yield 'in do condition descendant' => [ + ') } }', + false, + ]; + yield 'in if condition' => [ + ') {} } }', + false, + ]; + yield 'in if condition descendant' => [ + ') {} } }', + false, + ]; + yield 'in while condition' => [ + ') {} } }', + false, + ]; + yield 'in while condition descendant' => [ + ') {} } }', + false, + ]; + yield 'statement visibility 4' => [ + ' }', + false, + ]; + yield 'statement visibility 5' => [ + ' }', + false, + ]; + yield 'statement after class' => [ + '<>', + false, + ]; + yield 'statement const value' => [ + ' }', + false, + ]; + yield 'statement const value 2' => [ + ' }', + false, + ]; + yield 'statement attribute' => [ + ']public function bar(){}}', + false, + ]; + yield 'after member access 1' => [ + '<> } }', + false, + ]; + yield 'after member access 2' => [ + '<> } }', + false, + ]; + yield 'after static access 1' => [ + ' } }', + false, + ]; + yield 'echo 1' => [ + '; $t = 2; } }', + false, + ]; + yield 'echo 2' => [ + '; $t = 2; } }', + false, + ]; + yield 'echo 3' => [ + '; } }', + false, + ]; + yield 'echo 4' => [ + ' } }', + false, + ]; + yield 'echo 5' => [ + ' $t = 1;} }', + false, + ]; + yield 'at the end of full name' => [ + ' ', + true, + ]; + yield 'parent is stmt, inside parens' => [ + ' ', + false, + ]; + } + #[DataProvider('provideClassMemberBody')] public function testClassMemberBody(string $source, bool $expected): void { @@ -99,20 +336,24 @@ public static function provideClassMemberBody(): Generator { yield 'property' => [ ' }', - true + true, ]; yield 'visibility 1' => [ ' }', - true + true, ]; yield 'visibility 2' => [ ' }', - true + true, ]; yield 'visibility 3' => [ ' }', true, ]; + yield 'method body' => [ + ' } }', + true, + ]; // todo... yield 'visibility 4' => [