Skip to content
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ Add new `use TraitName;` statements to a class, trait, or enum. This method auto

**Note:** Need to provide the full trait class name (FQCN); the method will import it automatically.

#### addItemToReturnArray

Add or update an item in the array returned by a method. The method must have a single `return` statement with an array literal. If a key is given and already exists, its value is updated; otherwise the item is appended. Works with classes, traits, and enums.

```php
new PHPFileBuilder(app_path('Models/User.php'))
->addItemToReturnArray('casts', 'RoleEnum::class', 'role')
->addItemToReturnArray('getAvailableRelations', 'logo')
->save();
```

#### addMethod

Add a new method to a class, trait, or enum. Throws `NodeAlreadyExistsException` if a method with the given name already exists.
Expand Down
9 changes: 9 additions & 0 deletions src/Builders/PHPFileBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
use PhpParser\ParserFactory;
use RonasIT\Larabuilder\DTO\MethodParamsList;
use RonasIT\Larabuilder\Enums\AccessModifierEnum;
use RonasIT\Larabuilder\Enums\DefaultValue;
use RonasIT\Larabuilder\Enums\InsertPositionEnum;
use RonasIT\Larabuilder\Exceptions\InvalidPHPFileException;
use RonasIT\Larabuilder\NodeTraverser;
use RonasIT\Larabuilder\Printer;
use RonasIT\Larabuilder\Visitors\AddImports;
use RonasIT\Larabuilder\Visitors\AddTraits;
use RonasIT\Larabuilder\Visitors\MethodVisitors\AddItemToReturnArray;
use RonasIT\Larabuilder\Visitors\MethodVisitors\AddMethod;
use RonasIT\Larabuilder\Visitors\MethodVisitors\InsertCodeToMethod;
use RonasIT\Larabuilder\Visitors\MethodVisitors\RemoveMethod;
Expand Down Expand Up @@ -95,6 +97,13 @@ public function addMethod(
return $this;
}

public function addItemToReturnArray(string $methodName, string $value, string|DefaultValue $key = DefaultValue::None): self
{
$this->traverser->addVisitor(new AddItemToReturnArray($methodName, $value, $key));

return $this;
}

public function insertCodeToMethod(string $methodName, string $code, InsertPositionEnum $position = InsertPositionEnum::End): self
{
$this->traverser->addVisitor(new InsertCodeToMethod($methodName, $code, $position));
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptions/MultipleReturnStatementsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace RonasIT\Larabuilder\Exceptions;

use Exception;

class MultipleReturnStatementsException extends Exception
{
public function __construct(string $method)
{
parent::__construct("Method '{$method}' contains multiple return statements.");
}
}
16 changes: 16 additions & 0 deletions src/Exceptions/UnexpectedReturnTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace RonasIT\Larabuilder\Exceptions;

use Exception;

class UnexpectedReturnTypeException extends Exception
{
public function __construct(string $method, string $expectedType, ?string $actualType = null)
{
parent::__construct(
"Method '{$method}' return value has unexpected type. Expected '{$expectedType}'"
. (!empty($actualType) ? ", actual '{$actualType}'." : '.'),
);
}
}
57 changes: 57 additions & 0 deletions src/Nodes/PreformattedExpression.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace RonasIT\Larabuilder\Nodes;

use Illuminate\Support\Str;
use PhpParser\Error;
use PhpParser\Node\Expr;
use PhpParser\ParserFactory;
use RonasIT\Larabuilder\Exceptions\InvalidPHPCodeException;

/**
* Used to insert expression code with saving original formatting
*/
class PreformattedExpression extends Expr
{
public function __construct(
public string $value,
public array $attributes = [],
) {
parent::__construct($this->attributes);

if ($this->isPlainStringValue($value)) {
$this->value = "'{$value}'";
} else {
$this->value = Str::chopStart($this->value, '<?php');
$this->value = trim($this->value);
$this->value = Str::chopEnd($this->value, ';');

$this->validatePHPCode($this->value);
}
}

public function getSubNodeNames(): array
{
return ['value'];
}

public function getType(): string
{
return 'Expr_PreformattedExpression';
}

protected function isPlainStringValue(string $value): bool
{
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $value) === 1
&& !in_array(strtolower($value), ['null', 'true', 'false']);
}

protected function validatePHPCode(string $code): void
{
try {
new ParserFactory()->createForHostVersion()->parse("<?php\n{$code}?>");

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 Reject statements before embedding expressions

When $value or $key is a syntactically valid PHP statement but not an expression (for example return 1; or if ($x) {}), this validation succeeds because it parses the snippet as a whole PHP file. The same raw text is then printed inside an array item expression by AddItemToReturnArray, producing an invalid PHP file instead of throwing InvalidPHPCodeException; validate these snippets specifically as expressions before accepting them.

Useful? React with 👍 / 👎.

} catch (Error) {
throw new InvalidPHPCodeException($code);
}
}
}
13 changes: 12 additions & 1 deletion src/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PhpParser\PrettyPrinter\Standard;
use RonasIT\Larabuilder\Enums\StatementAttributeEnum;
use RonasIT\Larabuilder\Nodes\PreformattedCode;
use RonasIT\Larabuilder\Nodes\PreformattedExpression;

class Printer extends Standard
{
Expand Down Expand Up @@ -98,9 +99,19 @@ protected function shouldAddNewlineBeforeIfTypeSame(Node $node, string $type): b
return $previousNode !== null && $previousNode instanceof $type;
}

protected function pExpr_PreformattedExpression(PreformattedExpression $node): string
{
return $this->formatPreformattedCode($node->value);
}

protected function pStmt_PreformattedCode(PreformattedCode $node): string
{
$value = $this->preparePreformattedCode($node->value);
return $this->formatPreformattedCode($node->value);
}

private function formatPreformattedCode(string $value): string
{
$value = $this->preparePreformattedCode($value);

$indentLength = strspn($value, " \t");
$indent = substr($value, 0, $indentLength);
Expand Down
108 changes: 108 additions & 0 deletions src/Visitors/MethodVisitors/AddItemToReturnArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace RonasIT\Larabuilder\Visitors\MethodVisitors;

use Illuminate\Support\Arr;
use PhpParser\Node;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Return_;
use RonasIT\Larabuilder\Contracts\UpdateNodeContract;
use RonasIT\Larabuilder\Enums\DefaultValue;
use RonasIT\Larabuilder\Exceptions\MultipleReturnStatementsException;
use RonasIT\Larabuilder\Exceptions\UnexpectedReturnTypeException;
use RonasIT\Larabuilder\Nodes\PreformattedExpression;
use RonasIT\Larabuilder\Printer;

class AddItemToReturnArray extends BaseMethodVisitor implements UpdateNodeContract
{
protected PreformattedExpression $valueExpr;
protected ?PreformattedExpression $keyExpr;

public function __construct(
protected string $methodName,
string $value,
string|DefaultValue $key = DefaultValue::None,
) {
parent::__construct($methodName);

$this->valueExpr = new PreformattedExpression($value);
$this->keyExpr = ($key === DefaultValue::None) ? null : new PreformattedExpression($key);
}

public function shouldUpdateNode(Node $node): bool
{
$isTarget = $node instanceof ClassMethod && $this->methodName === $node->name->name;

Comment thread
artengin marked this conversation as resolved.
if ($isTarget) {
$this->hasTargetMethod = true;
}

return $isTarget;
}

public function updateNode(Node $node): void
{
$returnNodes = $this->findReturnsInScope($node->stmts ?? []);

if (count($returnNodes) > 1) {
throw new MultipleReturnStatementsException($this->methodName);
}

$returnNode = $returnNodes[0] ?? null;

if (!$returnNode?->expr instanceof Array_) {
throw new UnexpectedReturnTypeException($this->methodName, 'array', $node->returnType?->toString());
}

if (empty($this->keyExpr)) {
$returnNode->expr->items[] = new ArrayItem($this->valueExpr);

return;
}

$printer = new Printer();

foreach ($returnNode->expr->items as $item) {
if ($item instanceof ArrayItem
&& !empty($item->key)
&& $printer->prettyPrintExpr($item->key) === $this->keyExpr->value
) {
$item->value = $this->valueExpr;

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 Reset by-reference flags when replacing array items

If the existing return array contains a keyed by-reference item, e.g. 'role' => &$role, this replacement leaves the original ArrayItem::$byRef flag set while swapping in the new preformatted expression. The printer will then emit invalid PHP such as 'role' => &'admin', so updating a valid return array can corrupt the file; reset byRef or replace the whole ArrayItem when changing the value.

Useful? React with 👍 / 👎.


return;
}
}

$returnNode->expr->items[] = new ArrayItem($this->valueExpr, $this->keyExpr);
}

protected function findReturnsInScope(array $nodes): array
{
$returns = [];

foreach ($nodes as $node) {
if ($node instanceof Return_) {
$returns[] = $node;

continue;
}

if ($node instanceof FunctionLike) {
continue;
}

foreach ($node->getSubNodeNames() as $name) {
foreach (Arr::wrap($node->$name) as $child) {
if ($child instanceof Node) {
$returns = [...$returns, ...$this->findReturnsInScope([$child])];
}
}
}
}

return $returns;
}
}
15 changes: 15 additions & 0 deletions src/Visitors/MethodVisitors/BaseMethodVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Trait_;
use RonasIT\Larabuilder\Exceptions\NodeNotExistException;
use RonasIT\Larabuilder\Visitors\AbstractNodeVisitor;

abstract class BaseMethodVisitor extends AbstractNodeVisitor
{
protected bool $hasTargetMethod = false;

public function __construct(
protected string $methodName,
) {
}

protected array $allowedParentNodesTypes = [
Class_::class,
Trait_::class,
Enum_::class,
];

protected function updatableNodeNotFoundHook(): void
{
if (!$this->hasTargetMethod) {
throw new NodeNotExistException('Method', $this->methodName);
}
}
}
12 changes: 2 additions & 10 deletions src/Visitors/MethodVisitors/InsertCodeToMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@
use PhpParser\Node\Stmt\Nop;
use RonasIT\Larabuilder\Contracts\UpdateNodeContract;
use RonasIT\Larabuilder\Enums\InsertPositionEnum;
use RonasIT\Larabuilder\Exceptions\NodeNotExistException;
use RonasIT\Larabuilder\Nodes\PreformattedCode;
use RonasIT\Larabuilder\Support\StatementDuplicateChecker;

class InsertCodeToMethod extends BaseMethodVisitor implements UpdateNodeContract
{
protected bool $hasTargetMethod = false;

protected PreformattedCode $code;
protected StatementDuplicateChecker $statementDuplicateChecker;

Expand All @@ -23,6 +20,8 @@ public function __construct(
string $code,
protected InsertPositionEnum $insertPosition,
) {
parent::__construct($methodName);

$this->code = new PreformattedCode($code);
$this->statementDuplicateChecker = new StatementDuplicateChecker();
}
Expand Down Expand Up @@ -50,11 +49,4 @@ public function updateNode(Node $node): void
? [$this->code, ...$separator, ...$existingStmts]
: [...$existingStmts, ...$separator, $this->code];
}

protected function updatableNodeNotFoundHook(): void
{
if (!$this->hasTargetMethod) {
throw new NodeNotExistException('Method', $this->methodName);
}
}
}
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
Loading