Skip to content

Commit 38bc7af

Browse files
authored
feat: add ArrayToArrGetRector (#382)
* feat: add ArrayToDataGetRector and corresponding tests to convert array access to data_get() helper * lint * fix: correct variable name in FuncCall arguments in ArrayToDataGetRector * feat: add ArrayToDataGetRector to convert array access to data_get() helper * feat: add ArrayToArrGetRector to convert array access to Arr::get() method call * fix: use FullyQualified directly in ArrayToArrGetRector for clarity * feat: enhance ArrayToArrGetRector to add default values
1 parent d459ec1 commit 38bc7af

File tree

8 files changed

+440
-1
lines changed

8 files changed

+440
-1
lines changed

docs/rector_rules_overview.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# 85 Rules Overview
1+
# 86 Rules Overview
22

33
## AbortIfRector
44

@@ -294,6 +294,26 @@ Move help facade-like function calls to constructor injection
294294

295295
<br>
296296

297+
## ArrayToArrGetRector
298+
299+
Convert array access to `Arr::get()` method call, skips null coalesce with throw expressions
300+
301+
- class: [`RectorLaravel\Rector\ArrayDimFetch\ArrayToArrGetRector`](../src/Rector/ArrayDimFetch/ArrayToArrGetRector.php)
302+
303+
```diff
304+
-$array['key'];
305+
-$array['nested']['key'];
306+
-$array['key'] ?? 'default';
307+
-$array['nested']['key'] ?? 'default';
308+
+\Illuminate\Support\Arr::get($array, 'key');
309+
+\Illuminate\Support\Arr::get($array, 'nested.key');
310+
+\Illuminate\Support\Arr::get($array, 'key', 'default');
311+
+\Illuminate\Support\Arr::get($array, 'nested.key', 'default');
312+
$array['key'] ?? throw new Exception('Required');
313+
```
314+
315+
<br>
316+
297317
## AssertSeeToAssertSeeHtmlRector
298318

299319
Replace assertSee with assertSeeHtml when testing HTML with escape set to false
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RectorLaravel\Rector\ArrayDimFetch;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Arg;
9+
use PhpParser\Node\Expr;
10+
use PhpParser\Node\Expr\ArrayDimFetch;
11+
use PhpParser\Node\Expr\BinaryOp\Coalesce;
12+
use PhpParser\Node\Expr\StaticCall;
13+
use PhpParser\Node\Expr\Throw_;
14+
use PhpParser\Node\Name\FullyQualified;
15+
use PhpParser\Node\Scalar;
16+
use PhpParser\Node\Scalar\String_;
17+
use RectorLaravel\AbstractRector;
18+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
19+
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
20+
21+
/**
22+
* @see \RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToArrGetRector\ArrayToArrGetRectorTest
23+
*/
24+
final class ArrayToArrGetRector extends AbstractRector
25+
{
26+
/**
27+
* @var ArrayDimFetch[]
28+
*/
29+
private array $processedArrayDimFetches = [];
30+
31+
public function getRuleDefinition(): RuleDefinition
32+
{
33+
return new RuleDefinition(
34+
'Convert array access to Arr::get() method call, skips null coalesce with throw expressions',
35+
[new CodeSample(
36+
<<<'CODE_SAMPLE'
37+
$array['key'];
38+
$array['nested']['key'];
39+
$array['key'] ?? 'default';
40+
$array['nested']['key'] ?? 'default';
41+
$array['key'] ?? throw new Exception('Required');
42+
CODE_SAMPLE,
43+
<<<'CODE_SAMPLE'
44+
\Illuminate\Support\Arr::get($array, 'key');
45+
\Illuminate\Support\Arr::get($array, 'nested.key');
46+
\Illuminate\Support\Arr::get($array, 'key', 'default');
47+
\Illuminate\Support\Arr::get($array, 'nested.key', 'default');
48+
$array['key'] ?? throw new Exception('Required');
49+
CODE_SAMPLE
50+
)]
51+
);
52+
}
53+
54+
/**
55+
* @return array<class-string<Node>>
56+
*/
57+
public function getNodeTypes(): array
58+
{
59+
return [ArrayDimFetch::class, Coalesce::class];
60+
}
61+
62+
/**
63+
* @param ArrayDimFetch|Coalesce $node
64+
*/
65+
public function refactor(Node $node): ?StaticCall
66+
{
67+
if ($node instanceof Coalesce) {
68+
$result = $this->refactorCoalesce($node);
69+
if ($result instanceof StaticCall && $node->left instanceof ArrayDimFetch) {
70+
$this->processedArrayDimFetches[] = $node->left;
71+
}
72+
73+
return $result;
74+
}
75+
76+
if ($node instanceof ArrayDimFetch) {
77+
if (in_array($node, $this->processedArrayDimFetches, true)) {
78+
return null;
79+
}
80+
81+
return $this->refactorArrayDimFetch($node);
82+
}
83+
84+
return null;
85+
}
86+
87+
private function refactorCoalesce(Coalesce $coalesce): ?StaticCall
88+
{
89+
if (! $coalesce->left instanceof ArrayDimFetch) {
90+
return null;
91+
}
92+
93+
if ($coalesce->right instanceof Throw_) {
94+
$this->markArrayDimFetchAsProcessed($coalesce->left);
95+
96+
return null;
97+
}
98+
99+
$staticCall = $this->createArrGetCall($coalesce->left);
100+
if (! $staticCall instanceof StaticCall) {
101+
return null;
102+
}
103+
104+
$staticCall->args[] = new Arg($coalesce->right);
105+
106+
return $staticCall;
107+
}
108+
109+
private function refactorArrayDimFetch(ArrayDimFetch $arrayDimFetch): ?StaticCall
110+
{
111+
return $this->createArrGetCall($arrayDimFetch);
112+
}
113+
114+
private function createArrGetCall(ArrayDimFetch $arrayDimFetch): ?StaticCall
115+
{
116+
if (! $this->isValidArrayDimFetch($arrayDimFetch)) {
117+
return null;
118+
}
119+
120+
$keyPath = $this->buildKeyPath($arrayDimFetch);
121+
if (! $keyPath instanceof Expr) {
122+
return null;
123+
}
124+
125+
$expr = $this->getRootVariable($arrayDimFetch);
126+
127+
return new StaticCall(
128+
new FullyQualified('Illuminate\Support\Arr'),
129+
'get',
130+
[
131+
new Arg($expr),
132+
new Arg($keyPath),
133+
]
134+
);
135+
}
136+
137+
private function isValidArrayDimFetch(ArrayDimFetch $arrayDimFetch): bool
138+
{
139+
return $arrayDimFetch->dim instanceof Scalar;
140+
}
141+
142+
private function buildKeyPath(ArrayDimFetch $arrayDimFetch): ?Expr
143+
{
144+
$keys = [];
145+
$current = $arrayDimFetch;
146+
147+
while ($current instanceof ArrayDimFetch) {
148+
if (! $this->isValidArrayDimFetch($current)) {
149+
return null;
150+
}
151+
152+
/** @var scalar $dim */
153+
$dim = $current->dim;
154+
array_unshift($keys, $dim);
155+
$current = $current->var;
156+
}
157+
158+
if (count($keys) === 0) {
159+
return null;
160+
}
161+
162+
if (count($keys) === 1) {
163+
return $keys[0];
164+
}
165+
166+
return $this->createDotNotationString($keys);
167+
}
168+
169+
/**
170+
* @param array<scalar> $keys
171+
*/
172+
private function createDotNotationString(array $keys): ?String_
173+
{
174+
$stringParts = [];
175+
176+
foreach ($keys as $key) {
177+
$constantValues = $this->getType($key)->getConstantScalarValues();
178+
179+
if ($constantValues === []) {
180+
return null;
181+
}
182+
183+
$value = $constantValues[0];
184+
185+
if (! is_string($value) && ! is_int($value)) {
186+
return null;
187+
}
188+
189+
$stringParts[] = (string) $value;
190+
}
191+
192+
return new String_(implode('.', $stringParts));
193+
}
194+
195+
private function getRootVariable(ArrayDimFetch $arrayDimFetch): Expr
196+
{
197+
$current = $arrayDimFetch;
198+
199+
while ($current instanceof ArrayDimFetch) {
200+
$current = $current->var;
201+
}
202+
203+
return $current;
204+
}
205+
206+
private function markArrayDimFetchAsProcessed(ArrayDimFetch $arrayDimFetch): void
207+
{
208+
$this->processedArrayDimFetches[] = $arrayDimFetch;
209+
210+
$current = $arrayDimFetch;
211+
while ($current instanceof ArrayDimFetch) {
212+
$this->processedArrayDimFetches[] = $current;
213+
$current = $current->var;
214+
}
215+
}
216+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToArrGetRector;
6+
7+
use Iterator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
10+
11+
final class ArrayToArrGetRectorTest extends AbstractRectorTestCase
12+
{
13+
public static function provideData(): Iterator
14+
{
15+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
16+
}
17+
18+
/**
19+
* @test
20+
*/
21+
#[DataProvider('provideData')]
22+
public function test(string $filePath): void
23+
{
24+
$this->doTestFile($filePath);
25+
}
26+
27+
public function provideConfigFilePath(): string
28+
{
29+
return __DIR__ . '/config/configured_rule.php';
30+
}
31+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToDataGetRector\Fixture;
4+
5+
class SomeClass
6+
{
7+
public function run()
8+
{
9+
$array = ['key' => 'value', 'nested' => ['inner' => 'data']];
10+
11+
// Simple array access
12+
$value = $array['key'];
13+
14+
// Nested array access
15+
$nested = $array['nested']['inner'];
16+
17+
// Multiple levels
18+
$data = $array['level1']['level2']['level3'];
19+
20+
// Integer keys
21+
$indexed = $array[0];
22+
$multiIndexed = $array[0][1];
23+
}
24+
}
25+
26+
?>
27+
-----
28+
<?php
29+
30+
namespace RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToDataGetRector\Fixture;
31+
32+
class SomeClass
33+
{
34+
public function run()
35+
{
36+
$array = ['key' => 'value', 'nested' => ['inner' => 'data']];
37+
38+
// Simple array access
39+
$value = \Illuminate\Support\Arr::get($array, 'key');
40+
41+
// Nested array access
42+
$nested = \Illuminate\Support\Arr::get($array, 'nested.inner');
43+
44+
// Multiple levels
45+
$data = \Illuminate\Support\Arr::get($array, 'level1.level2.level3');
46+
47+
// Integer keys
48+
$indexed = \Illuminate\Support\Arr::get($array, 0);
49+
$multiIndexed = \Illuminate\Support\Arr::get($array, '0.1');
50+
}
51+
}
52+
53+
?>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToDataGetRector\Fixture;
4+
5+
class SkipComplexKeys
6+
{
7+
public function run()
8+
{
9+
$array = ['key' => 'value'];
10+
$dynamicKey = 'key';
11+
12+
// Should skip variable keys
13+
$value = $array[$dynamicKey];
14+
15+
// Should skip method call keys
16+
$value2 = $array[$this->getKey()];
17+
18+
// Should skip complex expressions
19+
$value3 = $array['prefix' . $suffix];
20+
21+
// Should skip without dimensions
22+
$emptyDim = $array[];
23+
}
24+
25+
private function getKey()
26+
{
27+
return 'key';
28+
}
29+
}
30+
31+
?>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace RectorLaravel\Tests\Rector\ArrayDimFetch\ArrayToArrGetRector\Fixture;
4+
5+
class SkipThrowExpressions
6+
{
7+
public function run()
8+
{
9+
$array = ['key' => 'value'];
10+
11+
// Should skip throw expressions - no transformation
12+
$value = $array['key'] ?? throw new \Exception('Key not found');
13+
14+
// Should skip variable keys with throw - no transformation
15+
$dynamicKey = 'key';
16+
$value2 = $array[$dynamicKey] ?? throw new \RuntimeException('Missing');
17+
18+
// Should skip nested throw expressions - no transformation
19+
$value3 = $array['nested']['inner'] ?? throw new \InvalidArgumentException('Nested missing');
20+
}
21+
}
22+
23+
?>

0 commit comments

Comments
 (0)