Skip to content

Commit bb0b013

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

2 files changed

Lines changed: 189 additions & 3 deletions

File tree

src/PhpGenerator/Dumper.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,58 @@ 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+
$this->refMap = [];
41+
$parts = '';
42+
if ($this->references && is_array($var)) {
43+
$refs = [];
44+
$this->collectReferences($var, $refs);
45+
$refs = array_filter($refs, fn($ref) => $ref[0] >= 2);
46+
$n = 0;
47+
foreach ($refs as $refId => $_) {
48+
$this->refMap[$refId] = ++$n;
49+
}
50+
foreach ($this->refMap as $refId => $n) {
51+
$parts .= '$r[' . $n . '] = ' . $this->dumpVar($refs[$refId][1]) . '; ';
52+
}
53+
}
54+
55+
$expr = $this->dumpVar($var, [], 0, $column);
56+
57+
return $parts
58+
? "(static function () { {$parts}return $expr; })()"
59+
: $expr;
60+
}
61+
62+
63+
/**
64+
* @param mixed[] $var
65+
* @param array<string, array{int, mixed}> $refs
66+
*/
67+
private function collectReferences(array $var, array &$refs): void
68+
{
69+
foreach ($var as $k => $v) {
70+
$refId = (\ReflectionReference::fromArrayElement($var, $k))?->getId();
71+
if ($refId !== null) {
72+
$refs[$refId] ??= [0, $v];
73+
$refs[$refId][0]++;
74+
}
75+
76+
if (is_array($v) && ($refId === null || $refs[$refId][0] === 1)) {
77+
$this->collectReferences($v, $refs);
78+
}
79+
}
3780
}
3881

3982

@@ -109,7 +152,7 @@ private function dumpArray(array $var, array $parents, int $level, int $column):
109152
if (empty($var)) {
110153
return '[]';
111154

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

@@ -121,7 +164,16 @@ private function dumpArray(array $var, array $parents, int $level, int $column):
121164
$keyPart = $hideKeys && ($k !== $keys[0] || $k === 0)
122165
? ''
123166
: $this->dumpVar($k) . ' => ';
124-
$pairs[] = $keyPart . $this->dumpVar($v, $parents, $level + 1, strlen($keyPart) + 1); // 1 = comma after item
167+
168+
if (
169+
$this->references
170+
&& ($refId = (\ReflectionReference::fromArrayElement($var, $k))?->getId())
171+
&& isset($this->refMap[$refId])
172+
) {
173+
$pairs[] = $keyPart . '&$r[' . $this->refMap[$refId] . ']';
174+
} else {
175+
$pairs[] = $keyPart . $this->dumpVar($v, $parents, $level + 1, strlen($keyPart) + 1); // 1 = comma after item
176+
}
125177
}
126178

127179
$line = '[' . implode(', ', $pairs) . ']';

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)