src/ClosureStasis.php source

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