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
14 changes: 8 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,10 @@ For modifying classes, traits, enums, etc. The base class handles:
#### Contracts

- **`UpdateNodeContract`** — The visitor can update an existing node. Requires `shouldUpdateNode(Node): bool` and `updateNode(Node): void`. The base class iterates over child statements and calls `updateNode()` on the first match.
- **`InsertNodeContract`** — The visitor can insert a new node. Requires `getInsertableNode(): Node`. The base class handles positioning (via `NodeInserter`) and empty line insertion.
- **`InsertNodeContract`** — The visitor inserts a single node. Requires `getInsertableNode(): Node`. The base class handles positioning (via `NodeInserter`) and empty line insertion.
- **`InsertNodesContract`** — The visitor inserts multiple nodes with duplicate filtering. Requires `getInsertableNodes(): array` (returns all candidate nodes) and `getSubNodes(Node): array` (returns child nodes used for duplicate detection). The base class calls `filterExistingNodes()` to exclude already-present nodes before inserting.

A visitor may implement both contracts. In that case, update is attempted first — insertion happens only if no existing node matched.

**Bulk insertion visitors** extend `InsertNodesAbstractVisitor` (which extends `AbstractNodeVisitor`) and handle inserting multiple nodes with built-in duplicate filtering.
A visitor may implement `UpdateNodeContract` together with `InsertNodeContract` or `InsertNodesContract`. In that case, update is attempted first — insertion happens only if no existing node matched.

### App bootstrap visitors (`AbstractAppBootstrapVisitor`)

Expand All @@ -71,8 +70,11 @@ Located in `src/Support/`:

## Creating a New Visitor

1. Extend `AbstractNodeVisitor` (or `AbstractInsertNodesVisitor` for bulk insertions).
1. Extend `AbstractNodeVisitor`.
2. Set `$allowedParentNodesTypes` to the node types your visitor targets.
3. Implement `InsertNodeContract`, `UpdateNodeContract`, or both.
3. Implement the appropriate contract(s):
- `InsertNodeContract` — for inserting a single node, implement `getInsertableNode(): Node`
- `InsertNodesContract` — for inserting multiple nodes with dedup, implement `getInsertableNodes(): array` and `getSubNodes(Node): array`
- `UpdateNodeContract` — for updating an existing node, implement `shouldUpdateNode(Node): bool` and `updateNode(Node): void`
4. Add a corresponding fluent method in `PHPFileBuilder` that creates and registers the visitor.
5. Add fixture files and a test case.
12 changes: 12 additions & 0 deletions src/Contracts/InsertNodesContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace RonasIT\Larabuilder\Contracts;

use PhpParser\Node;

interface InsertNodesContract
{
public function getInsertableNodes(): array;

public function getSubNodes(Node $node): array;
}
79 changes: 0 additions & 79 deletions src/Visitors/AbstractInsertNodesVisitor.php

This file was deleted.

54 changes: 44 additions & 10 deletions src/Visitors/AbstractNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PhpParser\Node\Stmt\Trait_;
use PhpParser\NodeVisitorAbstract;
use RonasIT\Larabuilder\Contracts\InsertNodeContract;
use RonasIT\Larabuilder\Contracts\InsertNodesContract;
use RonasIT\Larabuilder\Contracts\UpdateNodeContract;
use RonasIT\Larabuilder\Enums\StatementAttributeEnum;
use RonasIT\Larabuilder\Exceptions\InvalidStructureTypeException;
Expand Down Expand Up @@ -75,9 +76,9 @@ protected function modify(Node $node): Node
$this->updatableNodeNotFoundHook();
}

return ($this instanceof InsertNodeContract)
? $this->insertNode($node)
: $node;
$this->insertNodes($node->stmts);

return $node;
}

protected function updatableNodeNotFoundHook(): void
Expand All @@ -96,17 +97,50 @@ protected function linkParents(Node $parent): void
}
}

/** @param Class_|Trait_|Enum_ $node */
private function insertNode(Node $node): Node
protected function insertNodes(array &$nodes): void
{
$this->nodeInserter ??= new NodeInserter();
$newNodes = match (true) {
$this instanceof InsertNodesContract => $this->filterExistingNodes($nodes),
$this instanceof InsertNodeContract => [$this->getInsertableNode()],
default => null,
};

$newNode = $this->getInsertableNode();
if (!empty($newNodes)) {
$this->nodeInserter ??= new NodeInserter();

$this->linkParents($newNode);
foreach ($newNodes as $newNode) {
$this->linkParents($newNode);
}

$this->nodeInserter->insertNodes($node->stmts, [$newNode]);
$this->nodeInserter->insertNodes($nodes, $newNodes);
}
}

return $node;
private function filterExistingNodes(array $nodes): array
{
$insertableNodes = $this->getInsertableNodes();

if (empty($insertableNodes)) {
return [];
}

$targetNodeClass = get_class($insertableNodes[0]);

$existingNames = [];

foreach ($nodes as $node) {
if (!($node instanceof $targetNodeClass)) {
continue;
}

foreach ($this->getSubNodes($node) as $childNode) {
$existingNames[] = (string) $childNode->name;
}
}

return array_values(array_filter(
$insertableNodes,
fn (Node $newNode) => !in_array((string) $this->getSubNodes($newNode)[0]->name, $existingNames),
));
}
}
34 changes: 13 additions & 21 deletions src/Visitors/AddImports.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,15 @@
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\UseItem;
use RonasIT\Larabuilder\Contracts\InsertNodesContract;

class AddImports extends AbstractInsertNodesVisitor
class AddImports extends AbstractNodeVisitor implements InsertNodesContract
{
protected array $allowedParentNodesTypes = self::ANY_TYPE;

public function __construct(array $imports)
{
$nodesToInsert = collect($imports)
->filter()
->unique();

parent::__construct(
nodesToInsert: $nodesToInsert,
targetNodeClass: Use_::class,
);
}

public function leaveNode(Node $node): Node
{
return $node;
public function __construct(
protected array $imports,
) {
}

public function afterTraverse(array $nodes): ?array
Expand All @@ -45,14 +34,17 @@ public function afterTraverse(array $nodes): ?array
return $nodes;
}

/** @param Use_ $node */
protected function getChildNodes(Node $node): array
public function getInsertableNodes(): array
{
return $node->uses;
return array_map(
fn ($import) => new Use_([new UseItem(new Name($import))]),
array_unique(array_filter($this->imports)),
);
}

protected function getInsertableNode(string $name): Node
/** @param Use_ $node */
public function getSubNodes(Node $node): array
{
return new Use_([new UseItem(new Name($name))]);
return $node->uses;
}
}
28 changes: 12 additions & 16 deletions src/Visitors/AddTraits.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,32 @@
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\Stmt\TraitUse;
use RonasIT\Larabuilder\Contracts\InsertNodesContract;

class AddTraits extends AbstractInsertNodesVisitor
class AddTraits extends AbstractNodeVisitor implements InsertNodesContract
{
protected array $allowedParentNodesTypes = [
Class_::class,
Trait_::class,
Enum_::class,
];

public function __construct(array $traits)
{
$nodesToInsert = collect($traits)
->filter()
->unique()
->map(fn ($trait) => class_basename($trait));
public function __construct(
protected array $traits,
) {
}

parent::__construct(
nodesToInsert: $nodesToInsert,
targetNodeClass: TraitUse::class,
public function getInsertableNodes(): array
{
return array_map(
fn ($trait) => new TraitUse([new Name(class_basename($trait))]),
array_unique(array_filter($this->traits)),
Comment on lines +29 to +30
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 Deduplicate trait names after basename normalization

getInsertableNodes() now calls array_unique() before class_basename(), so inputs like ['App\\Traits\\Loggable', 'Loggable'] produce two identical TraitUse nodes. filterExistingNodes() only removes traits already present in the file, not duplicates within the new candidate list, so this can emit duplicate use Loggable; entries and generate invalid class composition when the same trait is applied twice.

Useful? React with 👍 / 👎.

);
}

/** @param TraitUse $node */
protected function getChildNodes(Node $node): array
public function getSubNodes(Node $node): array
{
return $node->traits;
}

protected function getInsertableNode(string $name): Node
{
return new TraitUse([new Name($name)]);
}
}