Skip to content

Commit 3dd1fb7

Browse files
committed
Dumper: added support for preserving array references
1 parent c06d23b commit 3dd1fb7

2 files changed

Lines changed: 199 additions & 4 deletions

File tree

src/PhpGenerator/Dumper.php

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
namespace Nette\PhpGenerator;
1111

1212
use Nette;
13-
use function addcslashes, array_keys, array_shift, count, dechex, get_mangled_object_vars, implode, in_array, is_array, is_int, is_object, is_resource, is_string, ltrim, method_exists, ord, preg_match, preg_replace, preg_replace_callback, preg_split, range, serialize, str_contains, str_pad, str_repeat, str_replace, strlen, strrpos, strtoupper, substr, trim, unserialize, var_export;
13+
use function addcslashes, array_filter, array_keys, array_shift, count, dechex, get_mangled_object_vars, implode, in_array, is_array, is_int, is_object, is_resource, is_string, ltrim, method_exists, ord, preg_match, preg_replace, preg_replace_callback, preg_split, range, serialize, str_contains, str_pad, str_repeat, str_replace, strlen, strrpos, strtoupper, substr, trim, unserialize, var_export;
1414
use const PREG_SPLIT_DELIM_CAPTURE, STR_PAD_LEFT;
1515

1616

@@ -25,15 +25,20 @@ final class Dumper
2525
public int $wrapLength = 120;
2626
public string $indentation = "\t";
2727
public bool $customObjects = true;
28+
public bool $references = false;
2829
public DumpContext $context = DumpContext::Expression;
2930

31+
/** @var array<string, int> */
32+
private array $refMap = [];
33+
3034

3135
/**
3236
* Returns a PHP representation of a variable.
3337
*/
3438
public function dump(mixed $var, int $column = 0): string
3539
{
36-
return $this->dumpVar($var, [], 0, $column);
40+
return $this->dumpReferences($var)
41+
?? $this->dumpVar($var, column: $column);
3742
}
3843

3944

@@ -109,7 +114,7 @@ private function dumpArray(array $var, array $parents, int $level, int $column):
109114
if (empty($var)) {
110115
return '[]';
111116

112-
} elseif ($level > $this->maxDepth || in_array($var, $parents, strict: true)) {
117+
} elseif ($level > $this->maxDepth || !$this->references && in_array($var, $parents, strict: true)) {
113118
throw new Nette\InvalidStateException('Nesting level too deep or recursive dependency.');
114119
}
115120

@@ -121,7 +126,16 @@ private function dumpArray(array $var, array $parents, int $level, int $column):
121126
$keyPart = $hideKeys && ($k !== $keys[0] || $k === 0)
122127
? ''
123128
: $this->dumpVar($k) . ' => ';
124-
$pairs[] = $keyPart . $this->dumpVar($v, $parents, $level + 1, strlen($keyPart) + 1); // 1 = comma after item
129+
130+
if (
131+
$this->references
132+
&& ($refId = (\ReflectionReference::fromArrayElement($var, $k))?->getId())
133+
&& isset($this->refMap[$refId])
134+
) {
135+
$pairs[] = $keyPart . '&$r[' . $this->refMap[$refId] . ']';
136+
} else {
137+
$pairs[] = $keyPart . $this->dumpVar($v, $parents, $level + 1, strlen($keyPart) + 1); // 1 = comma after item
138+
}
125139
}
126140

127141
$line = '[' . implode(', ', $pairs) . ']';
@@ -226,6 +240,53 @@ private function dumpLiteral(Literal $var, int $level): string
226240
}
227241

228242

243+
private function dumpReferences(mixed $var): ?string
244+
{
245+
$this->refMap = $refs = [];
246+
if (!$this->references || !is_array($var)) {
247+
return null;
248+
}
249+
250+
$this->collectReferences($var, $refs);
251+
$refs = array_filter($refs, fn($ref) => $ref[0] >= 2);
252+
if (!$refs) {
253+
return null;
254+
}
255+
256+
$n = 0;
257+
foreach ($refs as $refId => $_) {
258+
$this->refMap[$refId] = ++$n;
259+
}
260+
261+
$preamble = '';
262+
foreach ($this->refMap as $refId => $n) {
263+
$preamble .= '$r[' . $n . '] = ' . $this->dumpVar($refs[$refId][1]) . '; ';
264+
}
265+
266+
return '(static function () { ' . $preamble . 'return ' . $this->dumpVar($var) . '; })()';
267+
}
268+
269+
270+
/**
271+
* @param mixed[] $var
272+
* @param array<string, array{int, mixed}> $refs
273+
*/
274+
private function collectReferences(array $var, array &$refs): void
275+
{
276+
foreach ($var as $k => $v) {
277+
$refId = (\ReflectionReference::fromArrayElement($var, $k))?->getId();
278+
if ($refId !== null) {
279+
$refs[$refId] ??= [0, $v];
280+
$refs[$refId][0]++;
281+
}
282+
283+
if (is_array($v) && ($refId === null || $refs[$refId][0] === 1)) {
284+
$this->collectReferences($v, $refs);
285+
}
286+
}
287+
}
288+
289+
229290
/**
230291
* Generates PHP statement. Supports placeholders: ? \? $? ->? ::? ...? ...?: ?*
231292
*/

tests/PhpGenerator/Dumper.ref.phpt

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
/**
4+
* Test: Nette\PhpGenerator\Dumper reference support
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\PhpGenerator\Dumper;
10+
use Tester\Assert;
11+
12+
require __DIR__ . '/../bootstrap.php';
13+
14+
15+
test('ref=false ignores references', function () {
16+
$a = 'hello';
17+
$arr = [&$a, &$a];
18+
$dumper = new Dumper;
19+
Assert::same("['hello', 'hello']", $dumper->dump($arr));
20+
});
21+
22+
23+
test('ref=true single-use reference is not tracked', function () {
24+
$a = 42;
25+
$arr = [&$a];
26+
$dumper = new Dumper;
27+
$dumper->references = true;
28+
Assert::same('[42]', $dumper->dump($arr));
29+
});
30+
31+
32+
test('ref=true shared reference', function () {
33+
$a = 'hello';
34+
$arr = [&$a, &$a];
35+
$dumper = new Dumper;
36+
$dumper->references = true;
37+
Assert::same("(static function () { \$r[1] = 'hello'; return [&\$r[1], &\$r[1]]; })()", $dumper->dump($arr));
38+
});
39+
40+
41+
test('ref=true mixed references and plain values', function () {
42+
$a = 'ref';
43+
$arr = ['plain', &$a, 'also plain', &$a];
44+
$dumper = new Dumper;
45+
$dumper->references = true;
46+
Assert::same("(static function () { \$r[1] = 'ref'; return ['plain', &\$r[1], 'also plain', &\$r[1]]; })()", $dumper->dump($arr));
47+
});
48+
49+
50+
test('ref=true with nested arrays', function () {
51+
$a = 42;
52+
$arr = [[&$a], [&$a]];
53+
$dumper = new Dumper;
54+
$dumper->references = true;
55+
Assert::same('(static function () { $r[1] = 42; return [[&$r[1]], [&$r[1]]]; })()', $dumper->dump($arr));
56+
});
57+
58+
59+
test('ref=true with named keys', function () {
60+
$a = 'val';
61+
$arr = ['x' => &$a, 'y' => &$a];
62+
$dumper = new Dumper;
63+
$dumper->references = true;
64+
Assert::same("(static function () { \$r[1] = 'val'; return ['x' => &\$r[1], 'y' => &\$r[1]]; })()", $dumper->dump($arr));
65+
});
66+
67+
68+
test('ref=true multiple reference groups', function () {
69+
$a = 'A';
70+
$b = 'B';
71+
$arr = [&$a, &$b, &$a, &$b];
72+
$dumper = new Dumper;
73+
$dumper->references = true;
74+
Assert::same("(static function () { \$r[1] = 'A'; \$r[2] = 'B'; return [&\$r[1], &\$r[2], &\$r[1], &\$r[2]]; })()", $dumper->dump($arr));
75+
});
76+
77+
78+
test('ref=true references reset between dump calls', function () {
79+
$dumper = new Dumper;
80+
$dumper->references = true;
81+
82+
$a = 1;
83+
Assert::same('(static function () { $r[1] = 1; return [&$r[1], &$r[1]]; })()', $dumper->dump([&$a, &$a]));
84+
85+
$b = 2;
86+
Assert::same('(static function () { $r[1] = 2; return [&$r[1], &$r[1]]; })()', $dumper->dump([&$b, &$b]));
87+
});
88+
89+
90+
test('ref=true cross-dependent values', function () {
91+
$a = 'x';
92+
$b = [1, 2, &$a];
93+
$c = [&$b, &$a, &$b];
94+
$dumper = new Dumper;
95+
$dumper->references = true;
96+
$result = $dumper->dump($c);
97+
Assert::contains('static function ()', $result);
98+
99+
// verify the generated code recreates correct references
100+
$reconstructed = eval('return ' . $result . ';');
101+
$reconstructed[1] = 'changed';
102+
Assert::same('changed', $reconstructed[0][2]);
103+
Assert::same('changed', $reconstructed[2][2]);
104+
$reconstructed[0][0] = 99;
105+
Assert::same(99, $reconstructed[2][0]);
106+
});
107+
108+
109+
test('ref=true recursive reference', function () {
110+
$arr = [1, 2];
111+
$arr[2] = &$arr;
112+
$dumper = new Dumper;
113+
$dumper->references = true;
114+
$result = $dumper->dump($arr);
115+
Assert::contains('static function ()', $result);
116+
117+
// verify recursive structure
118+
$reconstructed = eval('return ' . $result . ';');
119+
Assert::same(1, $reconstructed[0]);
120+
Assert::same(2, $reconstructed[1]);
121+
Assert::type('array', $reconstructed[2]);
122+
});
123+
124+
125+
test('ref=false throws on recursive array', function () {
126+
$arr = [1];
127+
$arr[1] = &$arr;
128+
$dumper = new Dumper;
129+
Assert::exception(
130+
fn() => $dumper->dump($arr),
131+
Nette\InvalidStateException::class,
132+
'%a%recursive%a%',
133+
);
134+
});

0 commit comments

Comments
 (0)