diff --git a/lib/Bridge/TolerantParser/ChainTolerantCompletor.php b/lib/Bridge/TolerantParser/ChainTolerantCompletor.php index 60184a04..59fbcc09 100644 --- a/lib/Bridge/TolerantParser/ChainTolerantCompletor.php +++ b/lib/Bridge/TolerantParser/ChainTolerantCompletor.php @@ -12,8 +12,6 @@ class ChainTolerantCompletor implements Completor { - use ResolveAliasSuggestionsTrait; - /** * @var Parser */ @@ -38,7 +36,8 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato $truncatedSource = $this->truncateSource((string) $source, $byteOffset->toInt()); $node = $this->parser->parseSourceFile($truncatedSource)->getDescendantNodeAtPosition( - // the parser requires the byte offset, not the char offset + // use strlen because the parser requires the byte offset, not the char offset + // But we need to recalculate it because we removed trailing spaces when truncating strlen($truncatedSource) ); @@ -55,16 +54,9 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato continue; } - $importTable = $this->getClassImportTablesForNode($completionNode); $suggestions = $tolerantCompletor->complete($completionNode, $source, $byteOffset); - foreach ($suggestions as $suggestion) { - // Trick to avoid any BC break when converting to an array - // https://www.php.net/manual/fr/language.generators.syntax.php#control-structures.yield.from - foreach ($this->resolveAliasSuggestions($importTable, $suggestion) as $resolvedSuggestion) { - yield $resolvedSuggestion; - } - } + yield from $suggestions; $isComplete = $isComplete && $suggestions->getReturn(); } diff --git a/lib/Bridge/TolerantParser/ResolveAliasSuggestionCompletor.php b/lib/Bridge/TolerantParser/ResolveAliasSuggestionCompletor.php new file mode 100644 index 00000000..191615bc --- /dev/null +++ b/lib/Bridge/TolerantParser/ResolveAliasSuggestionCompletor.php @@ -0,0 +1,150 @@ +decorated = $decorated; + $this->parser = $parser ?: new Parser(); + } + + /** + * {@inheritDoc} + */ + public function complete(TextDocument $source, ByteOffset $byteOffset): Generator + { + $importTable = $this->getClassImportTableAtPosition($source, $byteOffset); + $suggestions = $this->decorated->complete($source, $byteOffset); + + foreach ($suggestions as $suggestion) { + $resolvedSuggestions = $this->resolveAliasSuggestions($importTable, $suggestion); + + // Trick to avoid any BC break when converting to an array + // https://www.php.net/manual/fr/language.generators.syntax.php#control-structures.yield.from + foreach ($resolvedSuggestions as $resolvedSuggestion) { + yield $resolvedSuggestion; + } + } + + return $suggestions->getReturn(); + } + + private function truncateSource(string $source, int $byteOffset): string + { + // truncate source at byte offset - we don't want the rest of the source + // file contaminating the completion (for example `$foo($<>\n $bar = + // ` will evaluate the Variable node as an expression node with a + // double variable `$\n $bar = ` + $truncatedSource = substr($source, 0, $byteOffset); + + // determine the last non-whitespace _character_ offset + $characterOffset = OffsetHelper::lastNonWhitespaceCharacterOffset($truncatedSource); + + // truncate the source at the character offset + $truncatedSource = mb_substr($source, 0, $characterOffset); + + return $truncatedSource; + } + + /** + * Add suggestions when a class is already imported with an alias or when a relative name is abailable. + * + * Will update the suggestion to remove the import_name option if already imported. + * Will add a suggestion if the class is imported under an alias. + * Will add a suggestion if part of the namespace is imported (i.e. ORM\Column is a relative name). + * + * @param ResolvedName[] $importTable + * + * @return Suggestion[] + */ + private function resolveAliasSuggestions(array $importTable, Suggestion $suggestion): array + { + if (Suggestion::TYPE_CLASS !== $suggestion->type()) { + return [$suggestion]; + } + + $suggestionFqcn = $suggestion->nameImport(); + $originalName = $suggestion->name(); + $originalSnippet = $suggestion->snippet(); + $suggestions = [$suggestion->name() => $suggestion]; + + foreach ($importTable as $alias => $resolvedName) { + $importFqcn = $resolvedName->getFullyQualifiedNameText(); + + if (0 !== strpos($suggestionFqcn, $importFqcn)) { + continue; + } + + $name = $alias.substr($suggestionFqcn, strlen($importFqcn)); + $suggestions[$alias] = $suggestion->withoutNameImport()->withName($name); + + if ($originalSnippet && $originalName !== $name) { + $snippet = str_replace($originalName, $name, $originalSnippet); + $suggestions[$alias] = $suggestions[$alias]->withSnippet($snippet); + } + } + + return array_values($suggestions); + } + + /** + * @return ResolvedName[] + */ + private function getClassImportTableAtPosition(TextDocument $source, ByteOffset $byteOffset): array + { + // We only need the closest node to retrieve the import table + // It's not a big deal if it's not the completed node as long as it has + // the same import table + $node = $this->getClosestNodeAtPosition( + $this->parser->parseSourceFile((string) $source), + $byteOffset->toInt(), + ); + + try { + [$importTable] = $node->getImportTablesForCurrentScope(); + } catch (\Exception $e) { + // If the node does not have an import table (SourceFileNode for example) + $importTable = []; + } + + return $importTable; + } + + private function getClosestNodeAtPosition(SourceFileNode $sourceFileNode, int $position): Node + { + $lastNode = $sourceFileNode; + /** @var Node $node */ + foreach ($sourceFileNode->getDescendantNodes() as $node) { + if ($position < $node->getFullStart()) { + return $lastNode; + } + + $lastNode = $node; + } + + return $lastNode; + } +} diff --git a/lib/Bridge/TolerantParser/ResolveAliasSuggestionsTrait.php b/lib/Bridge/TolerantParser/ResolveAliasSuggestionsTrait.php deleted file mode 100644 index 0506b0e9..00000000 --- a/lib/Bridge/TolerantParser/ResolveAliasSuggestionsTrait.php +++ /dev/null @@ -1,58 +0,0 @@ -type()) { - return [$suggestion]; - } - - $suggestionFqcn = $suggestion->classImport(); - $suggestions = [$suggestion->name() => $suggestion]; - foreach ($importTable as $alias => $resolvedName) { - $importFqcn = $resolvedName->getFullyQualifiedNameText(); - - if (0 !== strpos($suggestionFqcn, $importFqcn)) { - continue; - } - - $name = $alias.substr($suggestionFqcn, strlen($importFqcn)); - - $suggestions[$alias] = $suggestion->withoutNameImport()->withName($name); - } - - return array_values($suggestions); - } - - /** - * @return ResolvedName[] - */ - private function getClassImportTablesForNode(Node $node): array - { - try { - [$importTable] = $node->getImportTablesForCurrentScope(); - } catch (\Exception $e) { - $importTable = []; - } - - return $importTable; - } -} diff --git a/lib/Bridge/TolerantParser/WorseReflection/DoctrineAnnotationCompletor.php b/lib/Bridge/TolerantParser/WorseReflection/DoctrineAnnotationCompletor.php index 0dcb39b6..15c8e81d 100644 --- a/lib/Bridge/TolerantParser/WorseReflection/DoctrineAnnotationCompletor.php +++ b/lib/Bridge/TolerantParser/WorseReflection/DoctrineAnnotationCompletor.php @@ -6,12 +6,12 @@ use Microsoft\PhpParser\Node; use Microsoft\PhpParser\Node\SourceFileNode; use Microsoft\PhpParser\Parser; -use Phpactor\Completion\Bridge\TolerantParser\ResolveAliasSuggestionsTrait; use Phpactor\Completion\Core\Completor; use Phpactor\Completion\Core\Completor\NameSearcherCompletor; use Phpactor\Completion\Core\Suggestion; use Phpactor\Completion\Core\Util\OffsetHelper; use Phpactor\ReferenceFinder\NameSearcher; +use Phpactor\ReferenceFinder\Search\NameSearchResult; use Phpactor\TextDocument\ByteOffset; use Phpactor\TextDocument\TextDocument; use Phpactor\WorseReflection\Core\Exception\NotFound; @@ -19,8 +19,6 @@ class DoctrineAnnotationCompletor extends NameSearcherCompletor implements Completor { - use ResolveAliasSuggestionsTrait; - /** * @var Reflector */ @@ -63,7 +61,6 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato return true; } - $importTable = $this->getClassImportTablesForNode($node); $suggestions = $this->completeName($annotation); foreach ($suggestions as $suggestion) { @@ -71,15 +68,19 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato continue; } - $resolvedSuggestions = $this->resolveAliasSuggestions($importTable, $suggestion); - foreach ($resolvedSuggestions as $resolvedSuggestion) { - yield $resolvedSuggestion->withSnippet($resolvedSuggestion->name().'($1)$0'); - } + yield $suggestion; } return $suggestions->getReturn(); } + protected function createSuggestionOptions(NameSearchResult $result): array + { + return array_merge(parent::createSuggestionOptions($result), [ + 'snippet' => (string) $result->name()->head() .'($1)$0', + ]); + } + private function truncateSource(string $source, int $byteOffset): string { // truncate source at byte offset - we don't want the rest of the source diff --git a/tests/Integration/Bridge/TolerantParser/DoctrineAnnotationCompletorTest.php b/tests/Integration/Bridge/TolerantParser/DoctrineAnnotationCompletorTest.php index 53e3ddf2..c06e0009 100644 --- a/tests/Integration/Bridge/TolerantParser/DoctrineAnnotationCompletorTest.php +++ b/tests/Integration/Bridge/TolerantParser/DoctrineAnnotationCompletorTest.php @@ -109,43 +109,6 @@ class Foo {} ] ]]; - - yield 'in a namespace with an import' => [ - <<<'EOT' - - */ -class Foo {} -EOT - , [ - [ - 'type' => Suggestion::TYPE_CLASS, - 'name' => 'APP\Entity', - 'short_description' => 'App\Annotation\Entity', - 'snippet' => 'APP\Entity($1)$0', - 'name_import' => null, - ], [ - 'type' => Suggestion::TYPE_CLASS, - 'name' => 'Entity', - 'short_description' => 'App\Annotation\Entity', - 'snippet' => 'Entity($1)$0', - 'name_import' => 'App\Annotation\Entity', - ] - ]]; - yield 'annotation on a node in the middle of the AST' => [ <<<'EOT' prophesize(Completor::class); + $completor = new ResolveAliasSuggestionCompletor($decoratedCompletor->reveal()); + + $decoratedCompletor->complete($textDocument, $byteOffset) + ->will(function () use ($completorSuggestion) { + yield $completorSuggestion; + + return true; + }) + ; + + $generator = $completor->complete($textDocument, $byteOffset); + $suggestions = iterator_to_array($generator, false); + + $this->assertCount(count($expectedSuggestions), $suggestions); + $this->assertTrue($generator->getReturn()); + $this->assertEqualsCanonicalizing($expectedSuggestions, $suggestions); + } + + public function provideSuggestionsToResolve(): iterable + { + $textDocumentFactory = function (array $data = []): TextDocument { + $useStatements = []; + foreach ($data as $alias => $fqcn) { + $alias = is_int($alias) ? null : $alias; + $useStatements[] = "use $fqcn".($alias ? " as $alias" : ''); + } + + $useStatements = implode(PHP_EOL, $useStatements); + + return TextDocumentBuilder::create( + <<build(); + }; + $computeByteOffset = function (TextDocument $textDocument): ByteOffset { + $matches = []; + preg_match('/Bar \$bar/u', (string) $textDocument, $matches, PREG_OFFSET_CAPTURE); + + return ByteOffset::fromInt($matches[0][1] + 2); + }; + $suggestion = Suggestion::createWithOptions('Bar', [ + 'short_description' => 'App\Foo\Bar', + 'type' => Suggestion::TYPE_CLASS, + 'name_import' => 'App\Foo\Bar', + ]); + + yield 'Class not imported yet' => [ + $textDocument = $textDocumentFactory(), + $computeByteOffset($textDocument), + $suggestion, + [$suggestion], + ]; + + yield 'Class imported without an alias' => [ + $textDocument = $textDocumentFactory(['App\Foo\Bar']), + $computeByteOffset($textDocument), + $suggestion, + [$suggestion->withoutNameImport()], + ]; + + yield 'Class imported with an alias' => [ + $textDocument = $textDocumentFactory(['Foobar' => 'App\Foo\Bar']), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion, + $suggestion->withoutNameImport()->withName('Foobar'), + ], + ]; + + yield 'Class imported with and without an alias' => [ + $textDocument = $textDocumentFactory([ + 'Foobar' => 'App\Foo\Bar', + 'App\Foo\Bar', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion->withoutNameImport(), + $suggestion->withoutNameImport()->withName('Foobar'), + ], + ]; + + yield 'Class imported with an aliased namespace' => [ + $textDocument = $textDocumentFactory([ + 'FOO' => 'App\Foo', + 'APP' => 'App', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion, + $suggestion->withoutNameImport()->withName('FOO\Bar'), + $suggestion->withoutNameImport()->withName('APP\Foo\Bar'), + ], + ]; + + yield 'Class imported with and without an aliased namespace' => [ + $textDocument = $textDocumentFactory([ + 'App\Foo\Bar', + 'FOO' => 'App\Foo', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion->withoutNameImport(), + $suggestion->withoutNameImport()->withName('FOO\Bar'), + ], + ]; + } + + public function provideAnnotationToResolve(): iterable + { + $textDocumentFactory = function (array $data = []): TextDocument { + $useStatements = []; + foreach ($data as $alias => $fqcn) { + $alias = is_int($alias) ? null : $alias; + $useStatements[] = "use $fqcn".($alias ? " as $alias" : ''); + } + + $useStatements = implode(PHP_EOL, $useStatements); + + return TextDocumentBuilder::create( + <<build(); + }; + $computeByteOffset = function (TextDocument $textDocument): ByteOffset { + $matches = []; + preg_match('/\* @Ba/u', (string) $textDocument, $matches, PREG_OFFSET_CAPTURE); + + return ByteOffset::fromInt($matches[0][1] + 5); + }; + $suggestion = Suggestion::createWithOptions('Bar', [ + 'short_description' => 'App\Foo\Bar', + 'type' => Suggestion::TYPE_CLASS, + 'name_import' => 'App\Foo\Bar', + 'snippet' => 'Bar($1)$0', + ]); + + yield 'Annotation not imported yet' => [ + $textDocument = $textDocumentFactory(), + $computeByteOffset($textDocument), + $suggestion, + [$suggestion], + ]; + + yield 'Annotation imported without an alias' => [ + $textDocument = $textDocumentFactory(['App\Foo\Bar']), + $computeByteOffset($textDocument), + $suggestion, + [$suggestion->withoutNameImport()], + ]; + + yield 'Annotation imported with an alias' => [ + $textDocument = $textDocumentFactory(['Foobar' => 'App\Foo\Bar']), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion, + $suggestion->withoutNameImport()->withName('Foobar')->withSnippet('Foobar($1)$0'), + ], + ]; + + yield 'Annotation imported with and without an alias' => [ + $textDocument = $textDocumentFactory([ + 'Foobar' => 'App\Foo\Bar', + 'App\Foo\Bar', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion->withoutNameImport(), + $suggestion->withoutNameImport()->withName('Foobar')->withSnippet('Foobar($1)$0'), + ], + ]; + + yield 'Annotation imported with an aliased namespace' => [ + $textDocument = $textDocumentFactory([ + 'FOO' => 'App\Foo', + 'APP' => 'App', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion, + $suggestion->withoutNameImport()->withName('FOO\Bar')->withSnippet('FOO\Bar($1)$0'), + $suggestion->withoutNameImport()->withName('APP\Foo\Bar')->withSnippet('APP\Foo\Bar($1)$0'), + ], + ]; + + yield 'Annotation imported with and without an aliased namespace' => [ + $textDocument = $textDocumentFactory([ + 'App\Foo\Bar', + 'FOO' => 'App\Foo', + ]), + $computeByteOffset($textDocument), + $suggestion, + [ + $suggestion->withoutNameImport(), + $suggestion->withoutNameImport()->withName('FOO\Bar')->withSnippet('FOO\Bar($1)$0'), + ], + ]; + } +} diff --git a/tests/Unit/Bridge/TolerantParser/ChainTolerantCompletorTest.php b/tests/Unit/Bridge/TolerantParser/ChainTolerantCompletorTest.php index 5dad256e..77581088 100644 --- a/tests/Unit/Bridge/TolerantParser/ChainTolerantCompletorTest.php +++ b/tests/Unit/Bridge/TolerantParser/ChainTolerantCompletorTest.php @@ -12,7 +12,6 @@ use Phpactor\Completion\Tests\TestCase; use Phpactor\TestUtils\ExtractOffset; use Phpactor\TextDocument\ByteOffset; -use Phpactor\TextDocument\TextDocument; use Phpactor\TextDocument\TextDocumentBuilder; use Prophecy\Argument; @@ -159,140 +158,6 @@ public function testExcludesNonQualifingClasses() $this->assertTrue($suggestions->getReturn()); } - /** - * @dataProvider provideSuggestionsToResolve - * - * @param Suggestion[] $expectedSuggestions - */ - public function testResolveImportName( - TextDocument $textDocument, - ByteOffset $byteOffset, - Suggestion $completorSuggestion, - array $expectedSuggestions - ): void { - $completor = $this->create([$this->completor1->reveal()]); - - $this->completor1->complete(Argument::type(Node::class), $textDocument, $byteOffset) - ->will(function () use ($completorSuggestion) { - yield $completorSuggestion; - - return false; - }) - ; - - $generator = $completor->complete($textDocument, $byteOffset); - $suggestions = iterator_to_array($generator); - - $this->assertCount(count($expectedSuggestions), $suggestions); - $this->assertFalse($generator->getReturn()); - $this->assertEqualsCanonicalizing($expectedSuggestions, $suggestions); - } - - public function provideSuggestionsToResolve(): iterable - { - $textDocumentFactory = function (array $data = []): TextDocument { - $useStatements = []; - foreach ($data as $alias => $fqcn) { - $alias = is_int($alias) ? null : $alias; - $useStatements[] = "use $fqcn".($alias ? " as $alias" : ''); - } - - $useStatements = implode(PHP_EOL, $useStatements); - - return TextDocumentBuilder::create( - <<build(); - }; - $computeByteOffset = function (TextDocument $textDocument): ByteOffset { - $matches = []; - preg_match('/Bar \$bar/u', (string) $textDocument, $matches, PREG_OFFSET_CAPTURE); - - return ByteOffset::fromInt($matches[0][1] + 2); - }; - $suggestion = Suggestion::createWithOptions('Bar', [ - 'short_description' => 'App\Foo\Bar', - 'type' => Suggestion::TYPE_CLASS, - 'name_import' => 'App\Foo\Bar', - ]); - - yield 'Not imported yet' => [ - $textDocument = $textDocumentFactory(), - $computeByteOffset($textDocument), - $suggestion, - [$suggestion], - ]; - - yield 'Imported without an alias' => [ - $textDocument = $textDocumentFactory(['App\Foo\Bar']), - $computeByteOffset($textDocument), - $suggestion, - [$suggestion->withoutNameImport()], - ]; - - yield 'Imported with an alias' => [ - $textDocument = $textDocumentFactory(['Foobar' => 'App\Foo\Bar']), - $computeByteOffset($textDocument), - $suggestion, - [ - $suggestion, - $suggestion->withoutNameImport()->withName('Foobar'), - ], - ]; - - yield 'Imported with and without an alias' => [ - $textDocument = $textDocumentFactory([ - 'Foobar' => 'App\Foo\Bar', - 'App\Foo\Bar', - ]), - $computeByteOffset($textDocument), - $suggestion, - [ - $suggestion->withoutNameImport(), - $suggestion->withoutNameImport()->withName('Foobar'), - ], - ]; - - yield 'Imported with an aliased namespace' => [ - $textDocument = $textDocumentFactory([ - 'FOO' => 'App\Foo', - 'APP' => 'App', - ]), - $computeByteOffset($textDocument), - $suggestion, - [ - $suggestion, - $suggestion->withoutNameImport()->withName('FOO\Bar'), - $suggestion->withoutNameImport()->withName('APP\Foo\Bar'), - ], - ]; - - yield 'Imported with and without an aliased namespace' => [ - $textDocument = $textDocumentFactory([ - 'App\Foo\Bar', - 'FOO' => 'App\Foo', - ]), - $computeByteOffset($textDocument), - $suggestion, - [ - $suggestion->withoutNameImport(), - $suggestion->withoutNameImport()->withName('FOO\Bar'), - ], - ]; - } - private function create(array $completors): ChainTolerantCompletor { return new ChainTolerantCompletor($completors);