Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ Insert the provided code into the specified method body at the desired position

Add new imports to the file. This method will add a new import only in case it does not exist yet, preventing duplicate `use` statements.

#### removeImports

Remove imports from the file. By default, only removes imports that are not used in the code, preventing breaking changes. Pass `true` as the second argument to force removal regardless of usage.

#### addTraits

Add new `use TraitName;` statements to a class, trait, or enum. This method automatically adds the corresponding `use` imports at the top of the file and prevents duplicate trait usages.
Expand Down
10 changes: 10 additions & 0 deletions src/Builders/PHPFileBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use RonasIT\Larabuilder\Visitors\PropertyVisitors\AddArrayPropertyItem;
use RonasIT\Larabuilder\Visitors\PropertyVisitors\RemoveArrayPropertyItem;
use RonasIT\Larabuilder\Visitors\PropertyVisitors\SetProperty;
use RonasIT\Larabuilder\Visitors\RemoveImport;

class PHPFileBuilder
{
Expand Down Expand Up @@ -72,6 +73,15 @@ public function addImports(array $imports): self
return $this;
}

public function removeImports(array $imports, bool $force = false): self
{
foreach ($imports as $import) {
$this->traverser->addVisitor(new RemoveImport($import, $force));
}

return $this;
}

public function addTraits(array $traits): self
{
$this->traverser->addVisitor(new AddTraits($traits));
Expand Down
12 changes: 12 additions & 0 deletions src/Visitors/AbstractNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;
Expand Down Expand Up @@ -102,6 +103,17 @@ protected function linkParents(Node $parent): void
}
}

protected function &getNamespaceStatements(array &$nodes): array
{
$targetNamespace = array_find($nodes, fn ($node) => $node instanceof Namespace_);

if (!is_null($targetNamespace)) {
return $targetNamespace->stmts;
}

return $nodes;
}

/** @param Class_|Trait_|Enum_ $node */
private function insertNode(Node $node): Node
{
Expand Down
10 changes: 1 addition & 9 deletions src/Visitors/AddImports.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\UseItem;

Expand All @@ -31,14 +30,7 @@ public function leaveNode(Node $node): Node

public function afterTraverse(array $nodes): ?array
{
$targetNamespace = array_find($nodes, fn ($node) => $node instanceof Namespace_);

if (!is_null($targetNamespace)) {
/** @var Namespace_ $targetNamespace */
$targetNodes = &$targetNamespace->stmts;
} else {
$targetNodes = &$nodes;
}
$targetNodes = &$this->getNamespaceStatements($nodes);

$this->insertNodes($targetNodes);

Expand Down
102 changes: 102 additions & 0 deletions src/Visitors/RemoveImport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace RonasIT\Larabuilder\Visitors;

use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\UseItem;
use PhpParser\NodeFinder;
use RonasIT\Larabuilder\Nodes\PreformattedCode;

class RemoveImport extends AbstractNodeVisitor
{
protected array $allowedParentNodesTypes = self::ANY_TYPE;

protected NodeFinder $nodeFinder;

public function __construct(
protected string $import,
protected bool $force = false,
) {
$this->nodeFinder = new NodeFinder();
}

public function afterTraverse(array $nodes): ?array
{
$targetNodes = &$this->getNamespaceStatements($nodes);

foreach ($targetNodes as $node) {
if ($node instanceof Use_ || $node instanceof GroupUse) {
$this->removeTargetImport($node, $targetNodes);
}
}

$targetNodes = array_filter($targetNodes, fn ($node) => !$this->isEmptyImportNode($node));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reindex filtered namespace statements

afterTraverse() assigns array_filter(...) directly to $targetNodes, but array_filter keeps original numeric keys. When this visitor is chained with addImports(), BaseNodeVisitorAbstract::getInsertIndex() uses those sparse keys as if they were positional indexes, so array_splice can insert a new use after the class (or below the import separator) if an earlier import was removed. This can produce invalid PHP in workflows like removeImports(...)->addImports(...); reindexing the filtered array (e.g., array_values) avoids the offset mismatch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array_values is not needed here, testRemoveImportsThenAddImports confirms this

Comment thread
artengin marked this conversation as resolved.

return $nodes;
}

protected function removeTargetImport(Use_|GroupUse $node, array $targetNodes): void
{
$prefix = $node instanceof GroupUse ? $node->prefix : null;

$node->uses = array_filter($node->uses, fn (UseItem $useItem) => !$this->shouldRemove($useItem, $targetNodes, $prefix));
}

protected function shouldRemove(UseItem $useItem, array $targetNodes, ?Name $prefix = null): bool
{
if ($this->resolveFqcn($useItem, $prefix) !== $this->import) {
return false;
}

if ($this->force) {
return true;
}

$resolvedName = $useItem->alias?->name ?? $useItem->name->getLast();

return !$this->isImportUsed($resolvedName, $targetNodes);
}

protected function resolveFqcn(UseItem $useItem, ?Name $prefix): string
{
return $prefix !== null
? $prefix->toString() . '\\' . $useItem->name->toString()
: $useItem->name->toString();
}

protected function isEmptyImportNode(Node $node): bool
{
return ($node instanceof Use_ || $node instanceof GroupUse) && empty($node->uses);
}

protected function isImportUsed(string $importName, array $targetNodes): bool
{
$nodesWithoutImports = array_filter($targetNodes, fn ($node) => !($node instanceof Use_) && !($node instanceof GroupUse));

if ($this->hasUsageOf($importName, $nodesWithoutImports)) {
Comment on lines +77 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve docblock-only imports

When a requested import is referenced only from PHPDoc, such as @return UserService or @var Collection<UserService>, the default removeImports(..., false) still deletes it because usage detection only scans AST nodes and never inspects doc comments. That breaks static-analysis and IDE type resolution even though the non-forced mode is documented as safe unless the import is unused; please include doc comments in the usage check before removing.

Useful? React with 👍 / 👎.

return true;
}

$preformattedNodes = $this->nodeFinder->find($nodesWithoutImports, fn (Node $node) => $node instanceof PreformattedCode);

foreach ($preformattedNodes as $preformattedNode) {
/** @var PreformattedCode $preformattedNode */
if ($this->hasUsageOf($importName, $preformattedNode->code)) {
return true;
}
}

return false;
}

protected function hasUsageOf(string $name, array $nodes): bool
{
return !empty($this->nodeFinder->findFirst(
$nodes,
fn (Node $node) => $node instanceof Name && get_class($node) === Name::class && $node->getFirst() === $name,
Comment thread
artengin marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict usage scan to class-like name contexts

The usage detector matches any plain Name node with the same first segment, regardless of context. This includes function and constant references, so a class import can be treated as "used" just because code calls a same-named function/constant, preventing cleanup of actually unused imports. That violates the method contract to remove unused imports safely and makes results depend on unrelated identifiers.

Useful? React with 👍 / 👎.

));
}
}
2 changes: 1 addition & 1 deletion tests/NodeInserterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function testInsertMixedNodes(): void
new TraitUse([new Name('NewTrait')]),
new ClassConst([new Const_('ANOTHER_CONST', new Int_(0))], Modifiers::PUBLIC),
new TraitUse([new Name('AnotherTrait')]),
], true);
]);

$this->assertSame(
$this->getFixture('class_with_mixed_nodes_inserted.php'),
Expand Down
100 changes: 99 additions & 1 deletion tests/PHPFileBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ public static function provideInsertDuplicateCode(): array
'code' => '$db->table(\'users\')->where(\'id\', 1)->first();',
],
[
'code' => 'Arr::map($arr, fn ($value) => str_replace(\'0\', \'1\', $value));',
'code' => 'Helpers\Arr::map($arr, fn ($value) => str_replace(\'0\', \'1\', $value));',
],
];
}
Expand Down Expand Up @@ -724,4 +724,102 @@ public function testRemoveMethod(string $structure, string $method, string $resu
->removeMethod($method)
->save();
}

public function testRemoveImportsUnused(): void
{
$file = $this->generateOriginalStructurePath('class.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'remove_unused_import.php'),
);

new PHPFileBuilder($file)
->removeImports([
'App\Service\UserService',
'Some\SomeTrait',
'Some\AnotherTrait',
'App\Service\UserService',
'App\Support\Traits\SecondTrait',
])
->save();
}

public function testRemoveImportsUsedSkipped(): void
{
$file = $this->generateOriginalStructurePath('class.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'class_unchanged.php'),
);

new PHPFileBuilder($file)
->removeImports([
'RonasIT\Support\Traits\FirstTrait',
'RonasIT\Support\Traits\SecondTrait',
])
->save();
}

public function testRemoveImportsForce(): void
{
$file = $this->generateOriginalStructurePath('class.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'remove_imports_force.php'),
);

new PHPFileBuilder($file)
->removeImports([
'RonasIT\Larabuilder\Tests\Support\FirstClass',
'Some\SomeTrait',
'RonasIT\Support\Traits\FirstTrait',
'App\Service\UserService',
'App\Support\Traits\SecondTrait',
'App\Support\Classname',
'Illuminate\Support',
], force: true)
->save();
}

public function testRemoveImportsAfterChanges(): void
{
$file = $this->generateOriginalStructurePath('class.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'remove_imports_after_changes.php'),
);

new PHPFileBuilder($file)
->insertCodeToMethod('someMethod', 'app(UserService::class)->doSomething();')
->removeImports([
'App\Service\UserService',
'App\Support\Classname',
])
->save();
}

public function testRemoveImportsThenAddImports(): void
{
$file = $this->generateOriginalStructurePath('class.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'remove_then_add_imports.php'),
);

new PHPFileBuilder($file)
->removeImports([
'RonasIT\Support\SecondTrait',
'RonasIT\Support\Traits\NewTrait',
'App\Support\Traits\SecondTrait',
'App\Support\Classname',
'Illuminate\Support',
], force: true)
->addImports(['App\New\Service'])
->save();
}
}
11 changes: 9 additions & 2 deletions tests/Support/OriginStructures/class.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
namespace RonasIT\Larabuilder\Tests\Support;

use RonasIT\Larabuilder\Tests\Support\FirstClass;
use Some\SomeTrait;
use Some\{SomeTrait, AnotherTrait};
use RonasIT\Support\Traits\FirstTrait;
use App\Service\UserService;
use RonasIT\Support\SecondTrait;
use RonasIT\Support\Traits\NewTrait as SomeTrait;
use App\Support\Traits\SecondTrait as UnusedTrait, App\Support\Classname;
use Illuminate\Support as Helpers;

/**
* Test
Expand All @@ -31,6 +36,8 @@ public function someMethod()

$db->table('users')->where('id', 1)->first();

Arr::map($arr, fn ($value) => str_replace('0', '1', $value));
Helpers\Arr::map($arr, fn ($value) => str_replace('0', '1', $value));

$x = \App\Service\UserService::CONST;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
namespace RonasIT\Larabuilder\Tests\Support;

use RonasIT\Larabuilder\Tests\Support\FirstClass;
use Some\SomeTrait;
use Some\{SomeTrait, AnotherTrait};
use RonasIT\Support\Traits\FirstTrait;
use App\Service\UserService;
use RonasIT\Support\SecondTrait;
use RonasIT\Support\Traits\NewTrait as SomeTrait;
use App\Support\Traits\SecondTrait as UnusedTrait, App\Support\Classname;
use Illuminate\Support as Helpers;

/**
* Test
Expand Down Expand Up @@ -39,7 +44,9 @@ public function someMethod()

$db->table('users')->where('id', 1)->first();

Arr::map($arr, fn ($value) => str_replace('0', '1', $value));
Helpers\Arr::map($arr, fn ($value) => str_replace('0', '1', $value));

$x = \App\Service\UserService::CONST;
}

public function newMethod()
Expand Down
11 changes: 9 additions & 2 deletions tests/fixtures/PHPFileBuilderTest/add_imports_to_class.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
namespace RonasIT\Larabuilder\Tests\Support;

use RonasIT\Larabuilder\Tests\Support\FirstClass;
use Some\SomeTrait;
use Some\{SomeTrait, AnotherTrait};
use RonasIT\Support\Traits\FirstTrait;
use App\Service\UserService;
use RonasIT\Support\SecondTrait;
use RonasIT\Support\Traits\NewTrait as SomeTrait;
use App\Support\Traits\SecondTrait as UnusedTrait, App\Support\Classname;
use Illuminate\Support as Helpers;
use RonasIT\Larabuilder\Tests\Support\SecondClass;
use RonasIT\Larabuilder\Tests\Support\ThirdClass;

Expand Down Expand Up @@ -33,6 +38,8 @@ public function someMethod()

$db->table('users')->where('id', 1)->first();

Arr::map($arr, fn ($value) => str_replace('0', '1', $value));
Helpers\Arr::map($arr, fn ($value) => str_replace('0', '1', $value));

$x = \App\Service\UserService::CONST;
}
}
Loading