Skip to content

Commit 7e113c0

Browse files
committed
Dumper: added support for preserving array references
1 parent 4f35f55 commit 7e113c0

2 files changed

Lines changed: 197 additions & 4 deletions

File tree

src/PhpGenerator/Dumper.php

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace Nette\PhpGenerator;
99

1010
use Nette;
11-
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;
11+
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;
1212
use const PREG_SPLIT_DELIM_CAPTURE, STR_PAD_LEFT;
1313

1414

@@ -23,15 +23,20 @@ final class Dumper
2323
public int $wrapLength = 120;
2424
public string $indentation = "\t";
2525
public bool $customObjects = true;
26+
public bool $references = false;
2627
public DumpContext $context = DumpContext::Expression;
2728

29+
/** @var array<string, int> */
30+
private array $refMap = [];
31+
2832

2933
/**
3034
* Returns a PHP representation of a variable.
3135
*/
3236
public function dump(mixed $var, int $column = 0): string
3337
{
34-
return $this->dumpVar($var, [], 0, $column);
38+
return $this->dumpReferences($var)
39+
?? $this->dumpVar($var, column: $column);
3540
}
3641

3742

@@ -107,7 +112,7 @@ private function dumpArray(array $var, array $parents, int $level, int $column):
107112
if (empty($var)) {
108113
return '[]';
109114

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

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

125139
$line = '[' . implode(', ', $pairs) . ']';
@@ -224,6 +238,53 @@ private function dumpLiteral(Literal $var, int $level): string
224238
}
225239

226240

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

tests/PhpGenerator/Dumper.ref.phpt

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

0 commit comments

Comments
 (0)