Repository: phpactor/phpactor (php)

Issue:
Suggestions to Simplify FQN
Import the class when use full FQN

from
```php
<?php

namespace Foobar\Baz;

class Foo
{
    public function __construct(\Foobar\B<>ar $bar)
    {
    }
}
```

to

```php
<?php

namespace Foobar\Baz;

use Foobar\Bar;

class Foo
{
    public function __construct(Bar $bar)
    {
    }
}
```

Pull request description:
Adding a code action for simplifying the FQN
Closes #1258

Implementing a code action that replaces the FQN with the class name and an import

Patch:
diff --git a/lib/CodeTransform/Adapter/WorseReflection/Refactor/WorseReplaceQualifierWithImport.php b/lib/CodeTransform/Adapter/WorseReflection/Refactor/WorseReplaceQualifierWithImport.php
new file mode 100644
index 00000000..cf1ff61a
--- /dev/null
+++ b/lib/CodeTransform/Adapter/WorseReflection/Refactor/WorseReplaceQualifierWithImport.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Phpactor\CodeTransform\Adapter\WorseReflection\Refactor;
+
+use Microsoft\PhpParser\Node\QualifiedName;
+use Microsoft\PhpParser\Parser;
+use Phpactor\CodeBuilder\Domain\BuilderFactory;
+use Phpactor\CodeBuilder\Domain\Code;
+use Phpactor\CodeBuilder\Domain\Updater;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
+use Phpactor\CodeTransform\Domain\SourceCode;
+use Phpactor\TextDocument\TextDocumentEdits;
+use Phpactor\TextDocument\TextEdit;
+use Phpactor\TextDocument\TextEdits;
+use Phpactor\WorseReflection\Core\Type\ClassType;
+use Phpactor\WorseReflection\Reflector;
+
+class WorseReplaceQualifierWithImport implements ReplaceQualifierWithImport
+{
+    private Reflector $reflector;
+
+    private BuilderFactory $factory;
+
+    private Updater $updater;
+
+    private Parser $parser;
+
+    public function __construct(
+        Reflector $reflector,
+        BuilderFactory $sourceBuilder,
+        Updater $updater,
+        Parser $parser = null
+    ) {
+        $this->reflector = $reflector;
+        $this->factory = $sourceBuilder;
+        $this->parser = $parser ?: new Parser();
+        $this->updater = $updater;
+    }
+
+    public function getTextEdits(SourceCode $sourceCode, int $offset): TextDocumentEdits
+    {
+        $symbolContext = $this->reflector
+            ->reflectOffset($sourceCode->__toString(), $offset)
+            ->symbolContext();
+        $type = $symbolContext->type();
+
+        if (!$type instanceof ClassType) {
+            return new TextDocumentEdits($sourceCode->uri(), TextEdits::none());
+        }
+
+        $textEdits = $this->getTextEditForImports($sourceCode, $type);
+
+        $newClassName = $type->name()->short();
+        $position = $symbolContext->symbol()->position();
+
+        return new TextDocumentEdits(
+            $sourceCode->uri(),
+            $textEdits->merge(TextEdits::fromTextEdits([
+                TextEdit::create($position->start(), $position->end() - $position->start(), $newClassName)
+            ]))
+        );
+    }
+
+    public function canReplaceWithImport(SourceCode $source, int $offset): bool
+    {
+        $node = $this->parser->parseSourceFile($source->__toString());
+        $targetNode = $node->getDescendantNodeAtPosition($offset);
+
+        if ($targetNode instanceof QualifiedName) {
+            return $targetNode->isFullyQualifiedName();
+        }
+
+        return false;
+    }
+
+    private function getTextEditForImports(SourceCode $sourceCode, ClassType $type): TextEdits
+    {
+        $sourceBuilder = $this->factory->fromSource($sourceCode);
+        $sourceBuilder->use((string) $type->name());
+
+        return $this->updater->textEditsFor(
+            $sourceBuilder->build(),
+            Code::fromString((string) $sourceCode)
+        );
+    }
+}
diff --git a/lib/CodeTransform/Domain/Refactor/ReplaceQualifierWithImport.php b/lib/CodeTransform/Domain/Refactor/ReplaceQualifierWithImport.php
new file mode 100644
index 00000000..801f924c
--- /dev/null
+++ b/lib/CodeTransform/Domain/Refactor/ReplaceQualifierWithImport.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Phpactor\CodeTransform\Domain\Refactor;
+
+use Phpactor\CodeTransform\Domain\SourceCode;
+use Phpactor\TextDocument\TextDocumentEdits;
+
+interface ReplaceQualifierWithImport
+{
+    public function getTextEdits(SourceCode $sourceCode, int $offset): TextDocumentEdits;
+
+    public function canReplaceWithImport(SourceCode $sourceCode, int $offset): bool;
+}
diff --git a/lib/Extension/CodeTransform/CodeTransformExtension.php b/lib/Extension/CodeTransform/CodeTransformExtension.php
index 85874ff7..41e2bf75 100644
--- a/lib/Extension/CodeTransform/CodeTransformExtension.php
+++ b/lib/Extension/CodeTransform/CodeTransformExtension.php
@@ -27,6 +27,7 @@ use Phpactor\CodeTransform\Adapter\WorseReflection\Helper\WorseMissingMethodFind
 use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseExtractMethod;
 use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseFillObject;
 use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseGenerateConstructor;
+use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseReplaceQualifierWithImport;
 use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseGenerateDecorator;
 use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseOverrideMethod;
 use Phpactor\CodeTransform\Adapter\WorseReflection\Helper\WorseInterestingOffsetFinder;
@@ -55,6 +56,7 @@ use Phpactor\CodeTransform\Domain\Refactor\ImportName;
 use Phpactor\CodeTransform\Domain\Refactor\OverrideMethod;
 use Phpactor\CodeTransform\Domain\Refactor\RenameVariable;
 use Phpactor\CodeTransform\Domain\Refactor\GenerateMethod;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
 use Phpactor\Extension\CodeTransform\Rpc\TransformHandler;
 use Phpactor\Extension\CodeTransform\Rpc\ClassNewHandler;
 use Phpactor\Extension\ClassToFile\ClassToFileExtension;
@@ -186,6 +188,14 @@ class CodeTransformExtension implements Extension
 
     private function registerRefactorings(ContainerBuilder $container): void
     {
+        $container->register(ReplaceQualifierWithImport::class, function (Container $container) {
+            return new WorseReplaceQualifierWithImport(
+                $container->get(WorseReflectionExtension::SERVICE_REFLECTOR),
+                $container->get(BuilderFactory::class),
+                $container->get(Updater::class)
+            );
+        });
+
         $container->register(ExtractConstant::class, function (Container $container) {
             return new WorseExtractConstant(
                 $container->get(WorseReflectionExtension::SERVICE_REFLECTOR),
diff --git a/lib/Extension/LanguageServerCodeTransform/CodeAction/ReplaceQualifierWithImportProvider.php b/lib/Extension/LanguageServerCodeTransform/CodeAction/ReplaceQualifierWithImportProvider.php
new file mode 100644
index 00000000..25c3be60
--- /dev/null
+++ b/lib/Extension/LanguageServerCodeTransform/CodeAction/ReplaceQualifierWithImportProvider.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServerCodeTransform\CodeAction;
+
+use Amp\CancellationToken;
+use Amp\Promise;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
+use Phpactor\CodeTransform\Domain\SourceCode;
+use Phpactor\Extension\LanguageServerBridge\Converter\PositionConverter;
+use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ReplaceQualifierWithImportCommand;
+use Phpactor\LanguageServerProtocol\CodeAction;
+use Phpactor\LanguageServerProtocol\Command;
+use Phpactor\LanguageServerProtocol\Range;
+use Phpactor\LanguageServerProtocol\TextDocumentItem;
+use Phpactor\LanguageServer\Core\CodeAction\CodeActionProvider;
+use function Amp\call;
+
+class ReplaceQualifierWithImportProvider implements CodeActionProvider
+{
+    public const KIND = 'refactor.class.simplify';
+
+    private ReplaceQualifierWithImport $replaceQualifierWithImport;
+
+    public function __construct(ReplaceQualifierWithImport $replaceQualifierWithImport)
+    {
+        $this->replaceQualifierWithImport = $replaceQualifierWithImport;
+    }
+
+    public function kinds(): array
+    {
+        return [
+            self::KIND
+        ];
+    }
+
+    public function provideActionsFor(TextDocumentItem $textDocument, Range $range, CancellationToken $cancel): Promise
+    {
+        return call(function () use ($textDocument, $range) {
+            if (!$this->replaceQualifierWithImport->canReplaceWithImport(
+                SourceCode::fromStringAndPath($textDocument->text, $textDocument->uri),
+                PositionConverter::positionToByteOffset($range->start, $textDocument->text)->toInt(),
+            )) {
+                return [];
+            }
+
+            return [
+                CodeAction::fromArray([
+                    'title' => 'Replace qualifier with import',
+                    'kind' => self::KIND,
+                    'diagnostics' => [],
+                    'command' => new Command(
+                        'Replace qualifier with import',
+                        ReplaceQualifierWithImportCommand::NAME,
+                        [
+                            $textDocument->uri,
+                            PositionConverter::positionToByteOffset($range->start, $textDocument->text)->toInt(),
+                        ]
+                    )
+                ])
+            ];
+        });
+    }
+}
diff --git a/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php b/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
index 478e6f07..c1ae28a4 100644
--- a/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
+++ b/lib/Extension/LanguageServerCodeTransform/LanguageServerCodeTransformExtension.php
@@ -3,6 +3,7 @@
 namespace Phpactor\Extension\LanguageServerCodeTransform;
 
 use Phpactor\CodeTransform\Domain\Helper\MissingMethodFinder;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
 use Phpactor\CodeTransform\Domain\Refactor\ExtractConstant;
 use Phpactor\CodeTransform\Domain\Refactor\ExtractExpression;
 use Phpactor\CodeTransform\Domain\Refactor\ExtractMethod;
@@ -29,8 +30,10 @@ use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateConstructo
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateDecoratorProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\GenerateMethodProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ImportNameProvider;
+use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ReplaceQualifierWithImportProvider;
 use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\TransformerCodeActionPovider;
 use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\CreateClassCommand;
+use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ReplaceQualifierWithImportCommand;
 use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ExtractConstantCommand;
 use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ExtractExpressionCommand;
 use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ExtractMethodCommand;
@@ -134,8 +137,21 @@ class LanguageServerCodeTransformExtension implements Extension
         }, [
             LanguageServerExtension::TAG_COMMAND => [
                 'name' => ExtractMethodCommand::NAME
+        ],
+        ]);
+
+        $container->register(ReplaceQualifierWithImportCommand::class, function (Container $container) {
+            return new ReplaceQualifierWithImportCommand(
+                $container->get(ClientApi::class),
+                $container->get(LanguageServerExtension::SERVICE_SESSION_WORKSPACE),
+                $container->get(ReplaceQualifierWithImport::class)
+            );
+        }, [
+            LanguageServerExtension::TAG_COMMAND => [
+                'name' => ReplaceQualifierWithImportCommand::NAME
             ],
         ]);
+
         $container->register(ExtractConstantCommand::class, function (Container $container) {
             return new ExtractConstantCommand(
                 $container->get(ClientApi::class),
@@ -206,6 +222,15 @@ class LanguageServerCodeTransformExtension implements Extension
                 $container->getParameter(CodeTransformExtension::PARAM_IMPORT_GLOBALS)
             );
         });
+
+        $container->register(ReplaceQualifierWithImportProvider::class, function (Container $container) {
+            return new ReplaceQualifierWithImportProvider(
+                $container->get(ReplaceQualifierWithImport::class)
+            );
+        }, [
+            LanguageServerExtension::TAG_CODE_ACTION_PROVIDER => [],
+        ]);
+
         $container->register(ImportNameProvider::class, function (Container $container) {
             return new ImportNameProvider(
                 $container->get(CandidateFinder::class),
diff --git a/lib/Extension/LanguageServerCodeTransform/LspCommand/ReplaceQualifierWithImportCommand.php b/lib/Extension/LanguageServerCodeTransform/LspCommand/ReplaceQualifierWithImportCommand.php
new file mode 100644
index 00000000..d6d8ff6d
--- /dev/null
+++ b/lib/Extension/LanguageServerCodeTransform/LspCommand/ReplaceQualifierWithImportCommand.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServerCodeTransform\LspCommand;
+
+use Amp\Promise;
+use Amp\Success;
+use Phpactor\CodeTransform\Domain\Exception\TransformException;
+use Phpactor\CodeTransform\Domain\SourceCode;
+use Phpactor\Extension\LanguageServerBridge\Converter\TextEditConverter;
+use Phpactor\LanguageServerProtocol\WorkspaceEdit;
+use Phpactor\LanguageServer\Core\Command\Command;
+use Phpactor\LanguageServer\Core\Server\ClientApi;
+use Phpactor\LanguageServer\Core\Workspace\Workspace;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
+
+class ReplaceQualifierWithImportCommand implements Command
+{
+    public const NAME = 'replace_qualifier_with_import';
+
+    private ClientApi $clientApi;
+
+    private Workspace $workspace;
+
+    private ReplaceQualifierWithImport $replaceQualifierWithImport;
+
+    public function __construct(
+        ClientApi $clientApi,
+        Workspace $workspace,
+        ReplaceQualifierWithImport $replaceQualifierWithImport
+    ) {
+        $this->clientApi = $clientApi;
+        $this->workspace = $workspace;
+        $this->replaceQualifierWithImport = $replaceQualifierWithImport;
+    }
+
+    /**
+     * @return Promise<?ApplyWorkspaceEditResponse>
+     */
+    public function __invoke(string $uri, int $offset): Promise
+    {
+        $textDocument = $this->workspace->get($uri);
+
+        try {
+            $textEdits = $this->replaceQualifierWithImport->getTextEdits(
+                SourceCode::fromStringAndPath($textDocument->text, $textDocument->uri),
+                $offset
+            );
+        } catch (TransformException $error) {
+            $this->clientApi->window()->showMessage()->warning($error->getMessage());
+            return new Success(null);
+        }
+
+        return $this->clientApi->workspace()->applyEdit(new WorkspaceEdit([
+            $uri => TextEditConverter::toLspTextEdits($textEdits->textEdits(), $textDocument->text)
+        ]), 'Expand Class');
+    }
+}


Test patch:
diff --git a/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/ReplaceQualifierWithImportTest.php b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/ReplaceQualifierWithImportTest.php
new file mode 100644
index 00000000..a2926df7
--- /dev/null
+++ b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/ReplaceQualifierWithImportTest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Phpactor\CodeTransform\Tests\Adapter\WorseReflection\Refactor;
+
+use Phpactor\CodeBuilder\Adapter\WorseReflection\WorseBuilderFactory;
+use Phpactor\CodeTransform\Adapter\WorseReflection\Refactor\WorseReplaceQualifierWithImport;
+use Phpactor\CodeTransform\Tests\Adapter\WorseReflection\WorseTestCase;
+use Phpactor\CodeTransform\Domain\SourceCode;
+
+class ReplaceQualifierWithImportTest extends WorseTestCase
+{
+    /**
+     * @dataProvider dataFQNToImport
+     */
+    public function testFQNToImport(string $test): void
+    {
+        [$source, $expected, $offset] = $this->sourceExpectedAndOffset(__DIR__ . '/fixtures/' . $test);
+
+        $replaceQualifierWithImport = new WorseReplaceQualifierWithImport(
+            $this->reflectorForWorkspace($source),
+            new WorseBuilderFactory($this->reflectorForWorkspace($source)),
+            $this->updater()
+        );
+
+        $textDocumentEdits = $replaceQualifierWithImport->getTextEdits(
+            SourceCode::fromStringAndPath($source, 'file:///source'),
+            $offset
+        );
+        $sourceCode = SourceCode::fromStringAndPath($source, 'file:///source');
+        $transformed = SourceCode::fromStringAndPath(
+            (string) $textDocumentEdits->textEdits()->apply($sourceCode),
+            $textDocumentEdits->uri()->path()
+        );
+
+        self::assertEquals(trim($expected), trim($transformed));
+    }
+
+    /**
+     * @return array<string,array<string>>
+     */
+    public function dataFQNToImport(): array
+    {
+        return [
+            'in an expression' => [ 'replaceQualifierWithImport1.test' ],
+            'in a parameter' => [ 'replaceQualifierWithImport2.test' ],
+        ];
+    }
+}
diff --git a/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport1.test b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport1.test
new file mode 100644
index 00000000..abe9f2ab
--- /dev/null
+++ b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport1.test
@@ -0,0 +1,16 @@
+// File Foo.php
+<?php
+
+namespace SomeNamespace;
+class Foo {}
+
+// File: source
+<?php
+echo 'Debugging ' . \SomeNamespace\F<>oo::class;
+
+// File: expected
+<?php
+
+use SomeNamespace\Foo;
+
+echo 'Debugging ' . Foo::class;
diff --git a/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport2.test b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport2.test
new file mode 100644
index 00000000..3e196fe9
--- /dev/null
+++ b/lib/CodeTransform/Tests/Adapter/WorseReflection/Refactor/fixtures/replaceQualifierWithImport2.test
@@ -0,0 +1,17 @@
+// File Foo.php
+<?php
+
+namespace SomeNamespace;
+class Foo {}
+
+// File: source
+<?php
+function test(\SomeNamespace\F<>oo $foo) { }
+
+// File: expected
+<?php
+
+use SomeNamespace\Foo;
+
+function test(Foo $foo) { }
+
diff --git a/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/ReplaceQualifierWithImportProviderTest.php b/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/ReplaceQualifierWithImportProviderTest.php
new file mode 100644
index 00000000..11ba6763
--- /dev/null
+++ b/lib/Extension/LanguageServerCodeTransform/Tests/Unit/CodeAction/ReplaceQualifierWithImportProviderTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServerCodeTransform\Tests\Unit\CodeAction;
+
+use Amp\CancellationTokenSource;
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\CodeTransform\Domain\Refactor\ReplaceQualifierWithImport;
+use Phpactor\CodeTransform\Domain\SourceCode;
+use Phpactor\Extension\LanguageServerCodeTransform\CodeAction\ReplaceQualifierWithImportProvider;
+use Phpactor\Extension\LanguageServerCodeTransform\LspCommand\ReplaceQualifierWithImportCommand;
+use Phpactor\LanguageServerProtocol\CodeAction;
+use Phpactor\LanguageServerProtocol\Command;
+use Phpactor\LanguageServerProtocol\TextDocumentItem;
+use Phpactor\LanguageServer\Test\ProtocolFactory;
+use Prophecy\PhpUnit\ProphecyTrait;
+use Prophecy\Prophecy\ObjectProphecy;
+use function Amp\Promise\wait;
+
+class ReplaceQualifierWithImportProviderTest extends TestCase
+{
+    use ProphecyTrait;
+    const EXAMPLE_SOURCE = 'foobar';
+    const EXAMPLE_FILE = 'file:///somefile.php';
+
+    /**
+     * @var ObjectProphecy<ReplaceQualifierWithImport>
+     */
+    private ObjectProphecy $replaceQualifierWithImport;
+
+    public function setUp(): void
+    {
+        $this->replaceQualifierWithImport = $this->prophesize(ReplaceQualifierWithImport::class);
+    }
+
+    /**
+     * @dataProvider provideActionsData
+     */
+    public function testProvideActions(bool $shouldSucceed, array $expectedValue): void
+    {
+        $textDocumentItem = new TextDocumentItem(self::EXAMPLE_FILE, 'php', 1, self::EXAMPLE_SOURCE);
+        $range = ProtocolFactory::range(0, 0, 0, 5);
+
+        $this->replaceQualifierWithImport
+            ->canReplaceWithImport(
+                SourceCode::fromStringAndPath($textDocumentItem->text, $textDocumentItem->uri),
+                $range->start->character,
+            )
+            ->willReturn($shouldSucceed)
+            ->shouldBeCalled();
+
+        $cancel = (new CancellationTokenSource())->getToken();
+        $this->assertEquals(
+            $expectedValue,
+            wait($this->createProvider()->provideActionsFor(
+                $textDocumentItem,
+                $range,
+                $cancel
+            ))
+        );
+    }
+
+    public function provideActionsData(): Generator
+    {
+        yield 'Fail' => [
+            false,
+            []
+        ];
+        yield 'Success' => [
+            true,
+            [
+                CodeAction::fromArray([
+                    'title' => 'Replace qualifier with import',
+                    'kind' => ReplaceQualifierWithImportProvider::KIND,
+                    'diagnostics' => [],
+                    'command' => new Command(
+                        'Replace qualifier with import',
+                        ReplaceQualifierWithImportCommand::NAME,
+                        [
+                            self::EXAMPLE_FILE,
+                            0
+                        ]
+                    )
+                ])
+            ]
+        ];
+    }
+
+    private function createProvider(): ReplaceQualifierWithImportProvider
+    {
+        return new ReplaceQualifierWithImportProvider($this->replaceQualifierWithImport->reveal());
+    }
+}