|
1
|
<?php |
|
2
|
|
|
3
|
declare(strict_types=1); |
|
4
|
|
|
5
|
namespace Serializor; |
|
6
|
|
|
7
|
use Closure; |
|
8
|
use PhpToken; |
|
9
|
use ReflectionFunction; |
|
10
|
use RuntimeException; |
|
11
|
|
|
12
|
/** |
|
13
|
* Stasis for anonymous closures with source code extraction. |
|
14
|
*/ |
|
15
|
final class ClosureStasis extends Stasis |
|
16
|
{ |
|
17
|
private string $file; |
|
18
|
private int $line; |
|
19
|
private string $code; |
|
20
|
private string $namespace; |
|
21
|
private ?string $scope = null; |
|
22
|
private ?object $this = null; |
|
23
|
private array $use = []; |
|
24
|
private bool $isStatic = false; |
|
25
|
private array $useStatements = []; |
|
26
|
|
|
27
|
private static array $codeMakers = []; |
|
28
|
private static array $functionCache = []; |
|
29
|
/** |
|
30
|
* Pre-processed file info cache. |
|
31
|
* @var array<string, array{tokens: PhpToken[], namespaces: array}> |
|
32
|
*/ |
|
33
|
private static array $fileInfoCache = []; |
|
34
|
|
|
35
|
public function __construct() {} |
|
36
|
|
|
37
|
public function __serialize(): array |
|
38
|
{ |
|
39
|
$data = [ |
|
40
|
'f' => $this->file, |
|
41
|
'l' => $this->line, |
|
42
|
'c' => $this->code, |
|
43
|
'n' => $this->namespace, |
|
44
|
]; |
|
45
|
if ($this->scope !== null) { |
|
46
|
$data['s'] = $this->scope; |
|
47
|
} |
|
48
|
if ($this->this !== null) { |
|
49
|
$data['t'] = $this->this; |
|
50
|
} |
|
51
|
if (!empty($this->use)) { |
|
52
|
$data['u'] = $this->use; |
|
53
|
} |
|
54
|
if ($this->isStatic) { |
|
55
|
$data['is'] = true; |
|
56
|
} |
|
57
|
if (!empty($this->useStatements)) { |
|
58
|
$data['us'] = $this->useStatements; |
|
59
|
} |
|
60
|
return $data; |
|
61
|
} |
|
62
|
|
|
63
|
public function __unserialize(array $data): void |
|
64
|
{ |
|
65
|
$this->file = $data['f']; |
|
66
|
$this->line = $data['l']; |
|
67
|
$this->code = $data['c']; |
|
68
|
$this->namespace = $data['n']; |
|
69
|
$this->scope = $data['s'] ?? null; |
|
70
|
$this->this = $data['t'] ?? null; |
|
71
|
$this->use = $data['u'] ?? []; |
|
72
|
$this->isStatic = $data['is'] ?? false; |
|
73
|
$this->useStatements = $data['us'] ?? []; |
|
74
|
} |
|
75
|
|
|
76
|
public function getClassName(): string |
|
77
|
{ |
|
78
|
return Closure::class; |
|
79
|
} |
|
80
|
|
|
81
|
/** |
|
82
|
* Simple if no captured variables or bound $this. |
|
83
|
*/ |
|
84
|
public function isSimple(): bool |
|
85
|
{ |
|
86
|
return empty($this->use) && $this->this === null; |
|
87
|
} |
|
88
|
|
|
89
|
/** |
|
90
|
* Get the use variables for transformation. |
|
91
|
*/ |
|
92
|
public function &getUse(): array |
|
93
|
{ |
|
94
|
return $this->use; |
|
95
|
} |
|
96
|
|
|
97
|
/** |
|
98
|
* Get the bound $this for transformation. |
|
99
|
*/ |
|
100
|
public function getThis(): ?object |
|
101
|
{ |
|
102
|
return $this->this; |
|
103
|
} |
|
104
|
|
|
105
|
/** |
|
106
|
* Set the bound $this (after transformation). |
|
107
|
*/ |
|
108
|
public function setThis(mixed $this_): void |
|
109
|
{ |
|
110
|
$this->this = $this_; |
|
111
|
} |
|
112
|
|
|
113
|
public static function fromClosure(Closure $value, ReflectionFunction $rf): ClosureStasis |
|
114
|
{ |
|
115
|
$frozen = new ClosureStasis(); |
|
116
|
|
|
117
|
$frozen->file = $rf->getFileName(); |
|
118
|
$frozen->line = $rf->getStartLine(); |
|
119
|
|
|
120
|
// PHP 8.5 changed getNamespaceName() to return empty for closures. |
|
121
|
// Fall back to extracting namespace from the scope class name. |
|
122
|
$namespace = $rf->getNamespaceName(); |
|
123
|
if ($namespace === '') { |
|
124
|
$closureScopeClass = $rf->getClosureScopeClass(); |
|
125
|
if ($closureScopeClass !== null) { |
|
126
|
$className = $closureScopeClass->getName(); |
|
127
|
$lastSlash = \strrpos($className, '\\'); |
|
128
|
$namespace = $lastSlash !== false ? \substr($className, 0, $lastSlash) : ''; |
|
129
|
} |
|
130
|
} |
|
131
|
$frozen->namespace = $namespace; |
|
132
|
|
|
133
|
$frozen->this = $rf->getClosureThis(); |
|
134
|
$closureScopeClass = $rf->getClosureScopeClass(); |
|
135
|
$frozen->scope = $closureScopeClass?->getName(); |
|
136
|
|
|
137
|
$frozen->use = $rf->getClosureUsedVariables(); |
|
138
|
$usedThis = false; |
|
139
|
$usedStatic = false; |
|
140
|
$isStaticFunction = false; |
|
141
|
$useStatements = []; |
|
142
|
$frozen->code = self::getCode($rf, $usedThis, $usedStatic, $isStaticFunction, $useStatements); |
|
143
|
|
|
144
|
if (!$usedThis) { |
|
145
|
$frozen->this = null; |
|
146
|
} |
|
147
|
if (!$usedStatic && !$usedThis) { |
|
148
|
$frozen->scope = null; |
|
149
|
} |
|
150
|
$frozen->isStatic = $isStaticFunction ?? false; |
|
151
|
$frozen->useStatements = $useStatements ?? []; |
|
152
|
|
|
153
|
return $frozen; |
|
154
|
} |
|
155
|
|
|
156
|
public function &getInstance(): mixed |
|
157
|
{ |
|
158
|
if ($this->hasInstance()) { |
|
159
|
return $this->getCachedInstance(); |
|
160
|
} |
|
161
|
|
|
162
|
$filteredUseStatements = []; |
|
163
|
foreach ($this->useStatements as $useStatement) { |
|
164
|
if (trim($this->namespace) === '' && !\str_contains($useStatement, '\\')) { |
|
165
|
continue; |
|
166
|
} |
|
167
|
$filteredUseStatements[] = $useStatement; |
|
168
|
} |
|
169
|
|
|
170
|
$useStatements = implode("\n", $filteredUseStatements); |
|
171
|
$isSimple = empty($this->use) && $this->this === null && $this->scope === null; |
|
172
|
|
|
173
|
if ($isSimple) { |
|
174
|
$code = "namespace {$this->namespace} { {$useStatements} return {$this->code}; }"; |
|
175
|
$hash = \md5($code); |
|
176
|
if (!isset(self::$codeMakers[$hash])) { |
|
177
|
ClosureStream::register(); |
|
178
|
self::$codeMakers[$hash] = require(ClosureStream::STREAM_PROTO . '://' . $code); |
|
179
|
} |
|
180
|
$result = self::$codeMakers[$hash]; |
|
181
|
} else { |
|
182
|
$code = <<<PHP |
|
183
|
namespace {$this->namespace} { |
|
184
|
{$useStatements} |
|
185
|
return static function(array &\$useVars, ?object \$thisObject, ?string \$scopeClass): \Closure { |
|
186
|
extract(\$useVars, \EXTR_OVERWRITE | \EXTR_REFS); |
|
187
|
return \Closure::bind({$this->code}, \$thisObject, \$scopeClass); |
|
188
|
}; |
|
189
|
} |
|
190
|
PHP; |
|
191
|
|
|
192
|
$hash = \md5($code); |
|
193
|
if (!isset(self::$codeMakers[$hash])) { |
|
194
|
ClosureStream::register(); |
|
195
|
self::$codeMakers[$hash] = require(ClosureStream::STREAM_PROTO . '://' . $code); |
|
196
|
} |
|
197
|
|
|
198
|
// Check if any dependency is still unresolved (circular reference case) |
|
199
|
$hasPending = false; |
|
200
|
foreach ($this->use as $v) { |
|
201
|
if ($v instanceof Stasis && !$v->hasInstance()) { |
|
202
|
$hasPending = true; |
|
203
|
break; |
|
204
|
} |
|
205
|
} |
|
206
|
if (!$hasPending && $this->this instanceof Stasis && !$this->this->hasInstance()) { |
|
207
|
$hasPending = true; |
|
208
|
} |
|
209
|
|
|
210
|
if ($hasPending) { |
|
211
|
// Create a lazy wrapper that resolves on first call |
|
212
|
$stasis = $this; |
|
213
|
$codeMaker = self::$codeMakers[$hash]; |
|
214
|
$realClosure = null; |
|
215
|
$result = function (...$args) use ($stasis, $codeMaker, &$realClosure) { |
|
216
|
if ($realClosure === null) { |
|
217
|
$use = []; |
|
218
|
foreach ($stasis->use as $k => $v) { |
|
219
|
$use[$k] = ($v instanceof Stasis) ? $v->getInstance() : $v; |
|
220
|
} |
|
221
|
$thisObj = $stasis->this; |
|
222
|
if ($thisObj instanceof Stasis) { |
|
223
|
$thisObj = $thisObj->getInstance(); |
|
224
|
} |
|
225
|
$scope = $thisObj !== null ? \get_class($thisObj) : $stasis->scope; |
|
226
|
$realClosure = $codeMaker($use, $thisObj, $scope); |
|
227
|
} |
|
228
|
return $realClosure(...$args); |
|
229
|
}; |
|
230
|
} else { |
|
231
|
// All dependencies resolved - build the closure directly |
|
232
|
$use = []; |
|
233
|
foreach ($this->use as $k => $v) { |
|
234
|
$use[$k] = ($v instanceof Stasis) ? $v->getInstance() : $v; |
|
235
|
} |
|
236
|
$thisObject = $this->this; |
|
237
|
if ($thisObject instanceof Stasis) { |
|
238
|
$thisObject = $thisObject->getInstance(); |
|
239
|
} |
|
240
|
$scopeClass = $thisObject !== null ? \get_class($thisObject) : $this->scope; |
|
241
|
|
|
242
|
$result = self::$codeMakers[$hash]($use, $thisObject, $scopeClass); |
|
243
|
} |
|
244
|
} |
|
245
|
|
|
246
|
$this->setInstance($result); |
|
247
|
return $result; |
|
248
|
} |
|
249
|
|
|
250
|
// ------------------------------------------------------------------------- |
|
251
|
// Source code extraction (moved from ClosureTransformer) |
|
252
|
// ------------------------------------------------------------------------- |
|
253
|
|
|
254
|
public static function getCode(ReflectionFunction $rf, bool &$usedThis = null, bool &$usedStatic = null, bool &$isStaticFunction = null, array &$useStatements = null): string |
|
255
|
{ |
|
256
|
$hash = Reflect::getHash($rf); |
|
257
|
if (isset(self::$functionCache[$hash])) { |
|
258
|
$usedThis = self::$functionCache[$hash]['usedThis']; |
|
259
|
$usedStatic = self::$functionCache[$hash]['usedStatic']; |
|
260
|
$useStatements = self::$functionCache[$hash]['useStatements']; |
|
261
|
return self::$functionCache[$hash]['code']; |
|
262
|
} |
|
263
|
$usedThis = null; |
|
264
|
$usedStatic = null; |
|
265
|
$isStaticFunction = false; |
|
266
|
$sourceFile = $rf->getFileName(); |
|
267
|
if (\str_contains($sourceFile, 'eval()\'d')) { |
|
268
|
throw new RuntimeException("Can't serialize a closure that was generated with eval()"); |
|
269
|
} |
|
270
|
|
|
271
|
$fileInfo = self::getFileInfo($sourceFile); |
|
272
|
$tokens = $fileInfo['tokens']; |
|
273
|
$tokenCount = count($tokens); |
|
274
|
$closureStartLine = $rf->getStartLine(); |
|
275
|
|
|
276
|
$nsInfo = self::findNamespaceForLine($fileInfo['namespaces'], $closureStartLine); |
|
277
|
$namespace = $nsInfo['ns'] ?? ''; |
|
278
|
$useStatements = $nsInfo['useStatements'] ?? []; |
|
279
|
|
|
280
|
$closureScopeClass = $rf->getClosureScopeClass(); |
|
281
|
$magicClass = \var_export($closureScopeClass?->getName() ?? '', true); |
|
282
|
$magicFunction = \var_export($rf->getName(), true); |
|
283
|
$magicMethod = \var_export( |
|
284
|
($closureScopeClass ? $closureScopeClass->getName() . '::' : '') . $rf->getName(), |
|
285
|
true |
|
286
|
); |
|
287
|
|
|
288
|
$startIdx = self::findLineOffset($tokens, $closureStartLine); |
|
289
|
if ($startIdx < 0) { |
|
290
|
throw new RuntimeException("Could not find closure start line {$closureStartLine} in {$sourceFile}"); |
|
291
|
} |
|
292
|
|
|
293
|
$closuresOnLine = self::findClosuresOnLine($tokens, $closureStartLine, $startIdx); |
|
294
|
$targetClosureIdx = self::matchClosureBySignature($closuresOnLine, $rf); |
|
295
|
$targetStartIdx = $targetClosureIdx !== null ? $closuresOnLine[$targetClosureIdx]['startIdx'] : null; |
|
296
|
|
|
297
|
$capture = false; |
|
298
|
$capturedTokens = []; |
|
299
|
$stackDepth = 0; |
|
300
|
$stack = []; |
|
301
|
|
|
302
|
for ($idx = $startIdx; $idx < $tokenCount; $idx++) { |
|
303
|
$token = $tokens[$idx]; |
|
304
|
|
|
305
|
if (!$capture && $token->line > $closureStartLine) { |
|
306
|
break; |
|
307
|
} |
|
308
|
|
|
309
|
if (!$capture) { |
|
310
|
if ($token->line !== $closureStartLine) { |
|
311
|
continue; |
|
312
|
} |
|
313
|
if ($targetStartIdx !== null && $idx !== $targetStartIdx) { |
|
314
|
continue; |
|
315
|
} |
|
316
|
if ($token->id === \T_STATIC) { |
|
317
|
$nextNonIgnorable = $idx + 1; |
|
318
|
while ($nextNonIgnorable < $tokenCount && $tokens[$nextNonIgnorable]->isIgnorable()) { |
|
319
|
$nextNonIgnorable++; |
|
320
|
} |
|
321
|
if ($nextNonIgnorable < $tokenCount && $tokens[$nextNonIgnorable]->id === \T_FUNCTION) { |
|
322
|
$capture = true; |
|
323
|
$isStaticFunction = true; |
|
324
|
} elseif ($nextNonIgnorable < $tokenCount && $tokens[$nextNonIgnorable]->id === \T_FN) { |
|
325
|
$capture = true; |
|
326
|
$isStaticFunction = true; |
|
327
|
} else { |
|
328
|
continue; |
|
329
|
} |
|
330
|
} elseif ($token->id === T_FUNCTION || $token->id === \T_FN) { |
|
331
|
$capture = true; |
|
332
|
} else { |
|
333
|
continue; |
|
334
|
} |
|
335
|
} |
|
336
|
if (!$token->isIgnorable()) { |
|
337
|
if ($stackDepth === 0 && \str_contains(",)}];", $token->text)) { |
|
338
|
break; |
|
339
|
} |
|
340
|
if (!$usedStatic && ($token->text === 'self' || $token->text === 'static' || $token->text === 'parent')) { |
|
341
|
$usedStatic = true; |
|
342
|
} |
|
343
|
if (!$usedThis && $token->id === T_VARIABLE && ($token->text === '$this')) { |
|
344
|
$usedThis = true; |
|
345
|
} |
|
346
|
} |
|
347
|
$capturedTokens[] = $token; |
|
348
|
if ($token->text === '{') { |
|
349
|
$stack[$stackDepth++] = '}'; |
|
350
|
} elseif ($token->text === '(') { |
|
351
|
$stack[$stackDepth++] = ')'; |
|
352
|
} elseif ($token->text === '[') { |
|
353
|
$stack[$stackDepth++] = ']'; |
|
354
|
} elseif ($stackDepth > 0 && $stack[$stackDepth - 1] === $token->text) { |
|
355
|
--$stackDepth; |
|
356
|
if ($stackDepth === 0 && $token->text === '}') { |
|
357
|
if ($token->line !== $rf->getEndLine() && $token->line === $rf->getStartLine()) { |
|
358
|
$capture = false; |
|
359
|
$capturedTokens = []; |
|
360
|
} else { |
|
361
|
break; |
|
362
|
} |
|
363
|
} |
|
364
|
} |
|
365
|
} |
|
366
|
$codes = []; |
|
367
|
foreach ($capturedTokens as $token) { |
|
368
|
$text = match ($token->id) { |
|
369
|
\T_CLASS_C => $magicClass, |
|
370
|
\T_FUNC_C => $magicFunction, |
|
371
|
\T_METHOD_C => $magicMethod, |
|
372
|
\T_NS_C => \var_export($namespace, true), |
|
373
|
default => $token->text, |
|
374
|
}; |
|
375
|
$codes[] = $text; |
|
376
|
} |
|
377
|
|
|
378
|
self::$functionCache[$hash] = [ |
|
379
|
'code' => \implode('', $codes), |
|
380
|
'usedThis' => $usedThis, |
|
381
|
'usedStatic' => $usedStatic, |
|
382
|
'useStatements' => $useStatements, |
|
383
|
]; |
|
384
|
|
|
385
|
return self::$functionCache[$hash]['code']; |
|
386
|
} |
|
387
|
|
|
388
|
private static function extractStatement(array &$tokens, int $startIndex): string |
|
389
|
{ |
|
390
|
$captured = []; |
|
391
|
for (; $startIndex < count($tokens); $startIndex++) { |
|
392
|
if ($tokens[$startIndex]->isIgnorable()) { |
|
393
|
$captured[] = ' '; |
|
394
|
} else { |
|
395
|
$captured[] = $tokens[$startIndex]->text; |
|
396
|
} |
|
397
|
if ($tokens[$startIndex]->text === ";") { |
|
398
|
break; |
|
399
|
} |
|
400
|
} |
|
401
|
return implode("", $captured); |
|
402
|
} |
|
403
|
|
|
404
|
/** |
|
405
|
* @param PhpToken[] $tokens |
|
406
|
* @return array Array of ['startIdx' => int, 'params' => string[], 'useVars' => string[]] |
|
407
|
*/ |
|
408
|
private static function findClosuresOnLine(array &$tokens, int $line, int $startIdx = 0): array |
|
409
|
{ |
|
410
|
$closures = []; |
|
411
|
$tokenCount = count($tokens); |
|
412
|
|
|
413
|
for ($i = $startIdx; $i < $tokenCount; $i++) { |
|
414
|
$token = $tokens[$i]; |
|
415
|
if ($token->line !== $line) { |
|
416
|
if ($token->line > $line) { |
|
417
|
break; |
|
418
|
} |
|
419
|
continue; |
|
420
|
} |
|
421
|
|
|
422
|
$isArrowFunc = false; |
|
423
|
$closureStartIdx = null; |
|
424
|
|
|
425
|
if ($token->id === \T_FN) { |
|
426
|
$isArrowFunc = true; |
|
427
|
$closureStartIdx = $i; |
|
428
|
} elseif ($token->id === \T_FUNCTION) { |
|
429
|
$nextNonWhitespace = $i + 1; |
|
430
|
while ($nextNonWhitespace < $tokenCount && $tokens[$nextNonWhitespace]->isIgnorable()) { |
|
431
|
$nextNonWhitespace++; |
|
432
|
} |
|
433
|
if ($nextNonWhitespace < $tokenCount && $tokens[$nextNonWhitespace]->text === '(') { |
|
434
|
$closureStartIdx = $i; |
|
435
|
} |
|
436
|
} elseif ($token->id === \T_STATIC) { |
|
437
|
$nextNonWhitespace = $i + 1; |
|
438
|
while ($nextNonWhitespace < $tokenCount && $tokens[$nextNonWhitespace]->isIgnorable()) { |
|
439
|
$nextNonWhitespace++; |
|
440
|
} |
|
441
|
if ($nextNonWhitespace < $tokenCount) { |
|
442
|
if ($tokens[$nextNonWhitespace]->id === \T_FN) { |
|
443
|
$isArrowFunc = true; |
|
444
|
$closureStartIdx = $nextNonWhitespace; |
|
445
|
$i = $nextNonWhitespace; |
|
446
|
} elseif ($tokens[$nextNonWhitespace]->id === \T_FUNCTION) { |
|
447
|
$afterFunc = $nextNonWhitespace + 1; |
|
448
|
while ($afterFunc < $tokenCount && $tokens[$afterFunc]->isIgnorable()) { |
|
449
|
$afterFunc++; |
|
450
|
} |
|
451
|
if ($afterFunc < $tokenCount && $tokens[$afterFunc]->text === '(') { |
|
452
|
$closureStartIdx = $i; |
|
453
|
$i = $nextNonWhitespace; |
|
454
|
} |
|
455
|
} |
|
456
|
} |
|
457
|
} |
|
458
|
|
|
459
|
if ($closureStartIdx === null) { |
|
460
|
continue; |
|
461
|
} |
|
462
|
|
|
463
|
$params = []; |
|
464
|
$useVars = []; |
|
465
|
$parenDepth = 0; |
|
466
|
$state = 'searching'; |
|
467
|
|
|
468
|
for ($j = $i + 1; $j < $tokenCount; $j++) { |
|
469
|
$t = $tokens[$j]; |
|
470
|
|
|
471
|
if ($t->isIgnorable()) { |
|
472
|
continue; |
|
473
|
} |
|
474
|
|
|
475
|
if ($state === 'searching') { |
|
476
|
if ($t->text === '(') { |
|
477
|
$state = 'in_params'; |
|
478
|
$parenDepth = 1; |
|
479
|
} |
|
480
|
} elseif ($state === 'in_params') { |
|
481
|
if ($t->text === '(') { |
|
482
|
$parenDepth++; |
|
483
|
} elseif ($t->text === ')') { |
|
484
|
$parenDepth--; |
|
485
|
if ($parenDepth === 0) { |
|
486
|
$state = 'after_params'; |
|
487
|
} |
|
488
|
} elseif ($t->id === \T_VARIABLE) { |
|
489
|
$params[] = substr($t->text, 1); |
|
490
|
} |
|
491
|
} elseif ($state === 'after_params') { |
|
492
|
if ($t->id === \T_USE) { |
|
493
|
$state = 'before_use_vars'; |
|
494
|
} elseif ($t->text === '{' || $t->text === '=>' || $t->text === ':') { |
|
495
|
break; |
|
496
|
} |
|
497
|
} elseif ($state === 'before_use_vars') { |
|
498
|
if ($t->text === '(') { |
|
499
|
$state = 'in_use'; |
|
500
|
$parenDepth = 1; |
|
501
|
} |
|
502
|
} elseif ($state === 'in_use') { |
|
503
|
if ($t->text === '(') { |
|
504
|
$parenDepth++; |
|
505
|
} elseif ($t->text === ')') { |
|
506
|
$parenDepth--; |
|
507
|
if ($parenDepth === 0) { |
|
508
|
break; |
|
509
|
} |
|
510
|
} elseif ($t->id === \T_VARIABLE) { |
|
511
|
$useVars[] = substr($t->text, 1); |
|
512
|
} |
|
513
|
} |
|
514
|
} |
|
515
|
|
|
516
|
$closures[] = [ |
|
517
|
'startIdx' => $closureStartIdx, |
|
518
|
'params' => $params, |
|
519
|
'useVars' => $useVars, |
|
520
|
]; |
|
521
|
} |
|
522
|
|
|
523
|
return $closures; |
|
524
|
} |
|
525
|
|
|
526
|
private static function matchClosureBySignature(array $closuresOnLine, ReflectionFunction $rf): ?int |
|
527
|
{ |
|
528
|
if (count($closuresOnLine) <= 1) { |
|
529
|
return count($closuresOnLine) === 1 ? 0 : null; |
|
530
|
} |
|
531
|
|
|
532
|
$expectedParams = []; |
|
533
|
foreach ($rf->getParameters() as $param) { |
|
534
|
$expectedParams[] = $param->getName(); |
|
535
|
} |
|
536
|
|
|
537
|
$expectedUseVars = array_keys($rf->getStaticVariables()); |
|
538
|
|
|
539
|
$matches = []; |
|
540
|
foreach ($closuresOnLine as $idx => $closureInfo) { |
|
541
|
$paramsMatch = $closureInfo['params'] === $expectedParams; |
|
542
|
|
|
543
|
$useVarsMatch = true; |
|
544
|
if (!empty($closureInfo['useVars']) || !empty($expectedUseVars)) { |
|
545
|
$foundUseVars = $closureInfo['useVars']; |
|
546
|
sort($foundUseVars); |
|
547
|
$expectedSorted = $expectedUseVars; |
|
548
|
sort($expectedSorted); |
|
549
|
|
|
550
|
if (!empty($closureInfo['useVars'])) { |
|
551
|
$useVarsMatch = $foundUseVars === $expectedSorted; |
|
552
|
} |
|
553
|
} |
|
554
|
|
|
555
|
if ($paramsMatch && $useVarsMatch) { |
|
556
|
$matches[] = $idx; |
|
557
|
} |
|
558
|
} |
|
559
|
|
|
560
|
if (count($matches) === 1) { |
|
561
|
return $matches[0]; |
|
562
|
} |
|
563
|
|
|
564
|
if (count($matches) === 0) { |
|
565
|
foreach ($closuresOnLine as $idx => $closureInfo) { |
|
566
|
if ($closureInfo['params'] === $expectedParams) { |
|
567
|
$matches[] = $idx; |
|
568
|
} |
|
569
|
} |
|
570
|
if (count($matches) === 1) { |
|
571
|
return $matches[0]; |
|
572
|
} |
|
573
|
} |
|
574
|
|
|
575
|
$details = []; |
|
576
|
foreach ($closuresOnLine as $idx => $info) { |
|
577
|
$paramStr = empty($info['params']) ? '()' : '($' . implode(', $', $info['params']) . ')'; |
|
578
|
$useStr = empty($info['useVars']) ? '' : ' use ($' . implode(', $', $info['useVars']) . ')'; |
|
579
|
$details[] = " #{$idx}: {$paramStr}{$useStr}"; |
|
580
|
} |
|
581
|
$expectedParamStr = empty($expectedParams) ? '()' : '($' . implode(', $', $expectedParams) . ')'; |
|
582
|
$expectedUseStr = empty($expectedUseVars) ? '' : ' [captures: $' . implode(', $', $expectedUseVars) . ']'; |
|
583
|
|
|
584
|
throw new SerializerError( |
|
585
|
"Cannot serialize closure: multiple closures found on the same line and cannot be uniquely distinguished.\n" . |
|
586
|
"Target closure signature: {$expectedParamStr}{$expectedUseStr}\n" . |
|
587
|
"Found closures:\n" . implode("\n", $details) . "\n" . |
|
588
|
"Tip: Place each closure on its own line, or use distinct parameter names." |
|
589
|
); |
|
590
|
} |
|
591
|
|
|
592
|
/** |
|
593
|
* @return array{tokens: PhpToken[], namespaces: array} |
|
594
|
*/ |
|
595
|
private static function getFileInfo(string $sourceFile): array |
|
596
|
{ |
|
597
|
if (isset(self::$fileInfoCache[$sourceFile])) { |
|
598
|
return self::$fileInfoCache[$sourceFile]; |
|
599
|
} |
|
600
|
|
|
601
|
$tokens = PhpToken::tokenize(file_get_contents($sourceFile)); |
|
602
|
$count = count($tokens); |
|
603
|
|
|
604
|
$magicDir = \var_export(\dirname($sourceFile), true); |
|
605
|
$magicFile = \var_export($sourceFile, true); |
|
606
|
|
|
607
|
$namespaces = []; |
|
608
|
$currentNs = ''; |
|
609
|
$currentNsStart = 1; |
|
610
|
$currentUseStatements = []; |
|
611
|
|
|
612
|
for ($i = 0; $i < $count; $i++) { |
|
613
|
$token = $tokens[$i]; |
|
614
|
|
|
615
|
if ($token->id === \T_DIR) { |
|
616
|
$tokens[$i] = new PhpToken(\T_CONSTANT_ENCAPSED_STRING, $magicDir, $token->line, $token->pos); |
|
617
|
} elseif ($token->id === \T_FILE) { |
|
618
|
$tokens[$i] = new PhpToken(\T_CONSTANT_ENCAPSED_STRING, $magicFile, $token->line, $token->pos); |
|
619
|
} elseif ($token->id === \T_LINE) { |
|
620
|
$tokens[$i] = new PhpToken(\T_LNUMBER, (string) $token->line, $token->line, $token->pos); |
|
621
|
} |
|
622
|
|
|
623
|
if ($token->id === \T_NAMESPACE) { |
|
624
|
if ($currentNs !== '' || !empty($currentUseStatements)) { |
|
625
|
$namespaces[] = [ |
|
626
|
'ns' => $currentNs, |
|
627
|
'useStatements' => $currentUseStatements, |
|
628
|
'startLine' => $currentNsStart, |
|
629
|
'endLine' => $token->line - 1, |
|
630
|
]; |
|
631
|
} |
|
632
|
$currentNs = ''; |
|
633
|
for ($j = $i + 1; $j < $count; $j++) { |
|
634
|
$t = $tokens[$j]; |
|
635
|
if ($t->id === \T_NAME_QUALIFIED || $t->id === \T_STRING) { |
|
636
|
$currentNs = $t->text; |
|
637
|
break; |
|
638
|
} |
|
639
|
if ($t->text === ';' || $t->text === '{') { |
|
640
|
break; |
|
641
|
} |
|
642
|
} |
|
643
|
$currentNsStart = $token->line; |
|
644
|
$currentUseStatements = []; |
|
645
|
} elseif ($token->id === \T_USE && $i >= 2) { |
|
646
|
$isClosureUse = false; |
|
647
|
for ($j = $i + 1; $j < $count; $j++) { |
|
648
|
if (!$tokens[$j]->isIgnorable()) { |
|
649
|
if ($tokens[$j]->text === '(') { |
|
650
|
$isClosureUse = true; |
|
651
|
} |
|
652
|
break; |
|
653
|
} |
|
654
|
} |
|
655
|
if (!$isClosureUse) { |
|
656
|
$currentUseStatements[] = self::extractStatement($tokens, $i); |
|
657
|
} |
|
658
|
} |
|
659
|
} |
|
660
|
|
|
661
|
$lastLine = $tokens[$count - 1]->line ?? PHP_INT_MAX; |
|
662
|
$namespaces[] = [ |
|
663
|
'ns' => $currentNs, |
|
664
|
'useStatements' => $currentUseStatements, |
|
665
|
'startLine' => $currentNsStart, |
|
666
|
'endLine' => $lastLine, |
|
667
|
]; |
|
668
|
|
|
669
|
self::$fileInfoCache[$sourceFile] = ['tokens' => $tokens, 'namespaces' => $namespaces]; |
|
670
|
return self::$fileInfoCache[$sourceFile]; |
|
671
|
} |
|
672
|
|
|
673
|
private static function findNamespaceForLine(array $namespaces, int $line): array |
|
674
|
{ |
|
675
|
foreach ($namespaces as $ns) { |
|
676
|
if ($line >= $ns['startLine'] && $line <= $ns['endLine']) { |
|
677
|
return $ns; |
|
678
|
} |
|
679
|
} |
|
680
|
return ['ns' => '', 'useStatements' => []]; |
|
681
|
} |
|
682
|
|
|
683
|
/** |
|
684
|
* @param PhpToken[] $tokens |
|
685
|
*/ |
|
686
|
private static function findLineOffset(array $tokens, int $line): int |
|
687
|
{ |
|
688
|
$low = 0; |
|
689
|
$high = count($tokens) - 1; |
|
690
|
|
|
691
|
while ($low <= $high) { |
|
692
|
$mid = ($low + $high) >> 1; |
|
693
|
$tokenLine = $tokens[$mid]->line; |
|
694
|
|
|
695
|
if ($tokenLine > $line) { |
|
696
|
$high = $mid - 1; |
|
697
|
} elseif ($tokenLine < $line) { |
|
698
|
$low = $mid + 1; |
|
699
|
} else { |
|
700
|
while ($mid > 0 && $tokens[$mid - 1]->line === $line) { |
|
701
|
$mid--; |
|
702
|
} |
|
703
|
return $mid; |
|
704
|
} |
|
705
|
} |
|
706
|
|
|
707
|
return -1; |
|
708
|
} |
|
709
|
} |
|
710
|
|