diff --git a/src/ReflectionClosure.php b/src/ReflectionClosure.php index a7f997c..e017030 100644 --- a/src/ReflectionClosure.php +++ b/src/ReflectionClosure.php @@ -4,13 +4,137 @@ namespace Serializor; +use Closure; +use PhpToken; use ReflectionFunction; -use Serializor\Transformers\ClosureTransformer; +use RuntimeException; + +use function file_get_contents; +use function implode; +use function str_contains; + +use const T_FN; +use const T_FUNCTION; +use const T_STATIC; final class ReflectionClosure extends ReflectionFunction { + /** @var array $functionCache */ + private static array $functionCache = []; + + /** @var array> $tokenCache */ + private static array $tokenCache = []; + + private FunctionDescription $functionDescription; + + /** @param callable-string|Closure $function */ + public function __construct(string|Closure $function) + { + parent::__construct($function); + + $this->functionDescription = static::$functionCache[Reflect::getHash($this)] + ??= $this->generateFunctionDescription(); + } + + private function generateFunctionDescription(): FunctionDescription + { + $usedThis = false; + $usedStatic = false; + $sourceFile = $this->getFileName(); + + if ($sourceFile === false) { + return new FunctionDescription( + code: '/* native code */', + usedThis: false, + usedStatic: false, + ); + } + + if (str_contains($sourceFile, 'eval()\'d')) { + throw new RuntimeException("Can't serialize a closure that was generated with eval()"); + } + $tokens = self::$tokenCache[$sourceFile] + ??= PhpToken::tokenize(file_get_contents($sourceFile)); + + $capture = false; + $capturedTokens = []; + $stackDepth = 0; + $stack = []; + + foreach ($tokens as $idx => $token) { + if (!$capture) { + if ($token->line === $this->getStartLine()) { + if ($token->is(T_STATIC) && $tokens[$idx + 2]?->is([T_FUNCTION, T_FN])) { + $capture = true; + } elseif ($token->is([T_FUNCTION, T_FN])) { + $capture = true; + } else { + continue; + } + } else { + continue; + } + } + if (!$token->isIgnorable()) { + if ($stackDepth === 0 && $token->is([',', ')', '}', ']', ';'])) { + break; + } + if (!$usedStatic && $token->is(['self', 'static', 'parent'])) { + $usedStatic = true; + } + if (!$usedThis && $token->is('$this')) { + $usedThis = true; + } + } + $capturedTokens[] = $token; + if ($token->text === '{') { + $stack[$stackDepth++] = '}'; + } elseif ($token->text === '(') { + $stack[$stackDepth++] = ')'; + } elseif ($token->text === '[') { + $stack[$stackDepth++] = ']'; + } elseif ($stackDepth > 0 && $stack[$stackDepth - 1] === $token->text) { + --$stackDepth; + if ($stackDepth === 0 && $token->text === '}') { + if ($token->line !== $this->getEndLine() && $token->line === $this->getStartLine()) { + $capture = false; + $capturedTokens = []; + } else { + break; + } + } + } + } + + return new FunctionDescription( + code: implode($capturedTokens), + usedThis: $usedThis, + usedStatic: $usedStatic, + ); + } + public function getCode(): string { - return ClosureTransformer::getCode($this, $usedThis, $usedStatic); + return $this->functionDescription->code; + } + + public function usedThis(): bool + { + return $this->functionDescription->usedThis; } + + public function usedStatic(): bool + { + return $this->functionDescription->usedStatic; + } +} + +/** @internal */ +final readonly class FunctionDescription +{ + public function __construct( + public string $code, + public bool $usedThis, + public bool $usedStatic, + ) {} } diff --git a/src/Transformers/ClosureTransformer.php b/src/Transformers/ClosureTransformer.php index 0c212ed..b22a17e 100644 --- a/src/Transformers/ClosureTransformer.php +++ b/src/Transformers/ClosureTransformer.php @@ -5,19 +5,24 @@ namespace Serializor\Transformers; use Closure; -use PhpToken; use ReflectionClass; -use ReflectionFunction; use ReflectionMethod; -use RuntimeException; use Serializor\ClosureStream; use Serializor\Reflect; +use Serializor\ReflectionClosure; use Serializor\SerializerError; use Serializor\Stasis; use Serializor\TransformerInterface; use WeakMap; +use function class_exists; +use function function_exists; +use function get_debug_type; use function hash; +use function is_array; +use function is_callable; +use function is_object; +use function is_string; /** * Provides serialization of Closures for Serializor. @@ -28,20 +33,32 @@ final class ClosureTransformer implements TransformerInterface { /** @var array $codeMakers */ private static array $codeMakers = []; - private static array $tokenCache = []; - private static array $functionCache = []; + + /** @var ?WeakMap $transformedObjects */ + private static ?WeakMap $transformedObjects; + + /** @var Closure(array):array $transformUseVariablesFunc */ + private Closure $transformUseVariablesFunc; + + /** @var Closure(array):array $resolveUseVariablesFunc */ + private Closure $resolveUseVariablesFunc; + /** - * @var null|WeakMap + * @param ?Closure(array):array $transformUseVariablesFunc + * @param ?Closure(array):array $resolveUseVariablesFunc */ - private static WeakMap $transformedObjects; - private ?Closure $transformUseVariablesFunc; - private ?Closure $resolveUseVariablesFunc; - public function __construct(?Closure $transformUseVariablesFunc = null, ?Closure $resolveUseVariablesFunc = null) { + /** @var WeakMap */ self::$transformedObjects ??= new WeakMap(); - $this->transformUseVariablesFunc = $transformUseVariablesFunc; - $this->resolveUseVariablesFunc = $resolveUseVariablesFunc; + + /** @var Closure(array):array */ + $this->transformUseVariablesFunc = $transformUseVariablesFunc + ?? static fn(array $useVariables): array => $useVariables; + + /** @var Closure(array):array */ + $this->resolveUseVariablesFunc = $resolveUseVariablesFunc + ?? static fn(array $useVariables): array => $useVariables; } public function transforms(mixed $value): bool @@ -51,7 +68,7 @@ public function transforms(mixed $value): bool public function resolves(Stasis $value): bool { - return $value->getClassName() === \Closure::class; + return $value->getClassName() === Closure::class; } public function transform(mixed $value): mixed @@ -64,33 +81,33 @@ public function transform(mixed $value): mixed return self::$transformedObjects[$value]; } - $rf = new ReflectionFunction($value); + $reflectionClosure = new ReflectionClosure($value); $frozen = new Stasis(Closure::class); - $closureThis = $rf->getClosureThis(); - $closureScopeClass = $rf->getClosureScopeClass(); - $closureCalledClass = $rf->getClosureCalledClass(); + $closureThis = $reflectionClosure->getClosureThis(); + $closureScopeClass = $reflectionClosure->getClosureScopeClass(); + $closureCalledClass = $reflectionClosure->getClosureCalledClass(); - $frozen->p['name'] = $rf->getName(); - $frozen->p['hash'] = Reflect::getHash($rf); + $frozen->p['name'] = $reflectionClosure->getName(); + $frozen->p['hash'] = Reflect::getHash($reflectionClosure); if ($closureThis) { - $frozen->p['callable'] = [&$closureThis, $rf->getName()]; + $frozen->p['callable'] = [$closureThis, $reflectionClosure->getName()]; } elseif ($closureCalledClass) { - $frozen->p['callable'] = [$closureCalledClass->getName(), $rf->getName()]; - } elseif (\function_exists($rf->getName())) { - $frozen->p['callable'] = $rf->getName(); + $frozen->p['callable'] = [$closureCalledClass->getName(), $reflectionClosure->getName()]; + } elseif (function_exists($reflectionClosure->getName())) { + $frozen->p['callable'] = $reflectionClosure->getName(); } else { $frozen->p['callable'] = null; } - $frozen->p['this'] = &$closureThis; + $frozen->p['this'] = $closureThis; $frozen->p['scope_class'] = $closureScopeClass?->getName(); $frozen->p['called_class'] = $closureCalledClass?->getName(); - $frozen->p['namespace'] = $rf->getNamespaceName(); + $frozen->p['namespace'] = $reflectionClosure->getNamespaceName(); /** * We can't serialize the code of native functions */ - if (!$rf->isUserDefined()) { + if ($reflectionClosure->isInternal()) { self::$transformedObjects[$value] = $frozen; self::$transformedObjects[$frozen] = $value; return $frozen; @@ -102,16 +119,16 @@ public function transform(mixed $value): mixed */ if ($closureThis !== null) { $rc = Reflect::getReflectionClass($closureThis); - if (self::resolveMethod($rc, $rf->getName())) { + if (self::resolveMethod($rc, $reflectionClosure->getName())) { self::$transformedObjects[$value] = $frozen; self::$transformedObjects[$frozen] = $value; return $frozen; } } if ($closureCalledClass !== null) { - $rm = self::resolveMethod($closureCalledClass, $rf->getName()); + $rm = self::resolveMethod($closureCalledClass, $reflectionClosure->getName()); if ($rm && $rm->isStatic()) { - $frozen->p['callable'] = [$closureCalledClass->getName(), $rf->getName()]; + $frozen->p['callable'] = [$closureCalledClass->getName(), $reflectionClosure->getName()]; self::$transformedObjects[$value] = $frozen; self::$transformedObjects[$frozen] = $value; return $frozen; @@ -121,16 +138,13 @@ public function transform(mixed $value): mixed self::$transformedObjects[$value] = $frozen; self::$transformedObjects[$frozen] = $value; - if ($this->transformUseVariablesFunc !== null) { - $frozen->p['use'] = ($this->transformUseVariablesFunc)($rf->getClosureUsedVariables()); - } else { - $frozen->p['use'] = $rf->getClosureUsedVariables(); - } - $frozen->p['code'] = self::getCode($rf, $usedThis, $usedStatic, $isStaticFunction); - if (!$usedThis) { + $frozen->p['use'] = ($this->transformUseVariablesFunc)($reflectionClosure->getClosureUsedVariables()); + + $frozen->p['code'] = $reflectionClosure->getCode(); + if (!$reflectionClosure->usedThis()) { $frozen->p['this'] = null; } - $frozen->p['is_static_function'] = $isStaticFunction; + $frozen->p['is_static_function'] = $reflectionClosure->usedStatic(); return $frozen; } @@ -144,12 +158,14 @@ public function resolve(mixed $value): mixed return self::$transformedObjects[$value]; } - if (\is_callable($value->p['callable'])) { + + /** @var array{object|string, string}|string $value->p['callable'] */ + if (is_callable($value->p['callable'])) { $result = Closure::fromCallable($value->p['callable']); self::$transformedObjects[$value] = $result; self::$transformedObjects[$result] = $value; return $result; - } elseif (\is_array($value->p['callable']) && \is_string($value->p['callable'][0]) && \class_exists($value->p['callable'][0])) { + } elseif (is_array($value->p['callable']) && is_string($value->p['callable'][0]) && class_exists($value->p['callable'][0])) { $callable = self::resolveCallable($value->p['callable']); if ($callable) { self::$transformedObjects[$value] = $callable; @@ -174,12 +190,12 @@ public function resolve(mixed $value): mixed self::$codeMakers[$hash] = require(ClosureStream::PROTOCOL . '://' . $code); } - if ($this->resolveUseVariablesFunc !== null) { - $use = ($this->resolveUseVariablesFunc)($value->p['use']); - } else { - $use = $value->p['use']; - } - + /** + * @var array $value->p['use'] + * @var ?object $value->p['this'] + * @var ?string $value->p['scope_class'] + */ + $use = ($this->resolveUseVariablesFunc)($value->p['use']); $result = self::$codeMakers[$hash]($use, $value->p['this'], $value->p['scope_class']); self::$transformedObjects[$value] = $result; @@ -188,19 +204,20 @@ public function resolve(mixed $value): mixed return $result; } + /** @param array{object|string, string}|string $callable */ private static function resolveCallable(array|string $callable): ?Closure { if (is_callable($callable)) { return Closure::fromCallable($callable); } - if (is_array($callable) && (\is_object($callable[0]) || class_exists($callable[0]))) { + if (is_array($callable) && (is_object($callable[0]) || class_exists($callable[0]))) { $rc = Reflect::getReflectionClass($callable[0]); $rm = self::resolveMethod($rc, $callable[1]); if ($rm) { if (!$rm->isStatic() && is_object($callable[0])) { return $rm->getClosure($callable[0]); } else { - return $rm->getclosure(); + return $rm->getClosure(); } } } @@ -217,121 +234,4 @@ private static function resolveMethod(ReflectionClass $rc, string $methodName): } while ($crc = $crc->getParentClass()); return null; } - - public static function getCode(ReflectionFunction $rf, bool &$usedThis = null, bool &$usedStatic = null, bool &$isStaticFunction = null): string - { - $hash = Reflect::getHash($rf); - if (isset(self::$functionCache[$hash])) { - $usedThis = self::$functionCache[$hash]['usedThis']; - $usedStatic = self::$functionCache[$hash]['usedStatic']; - return self::$functionCache[$hash]['code']; - } - $usedThis = null; - $usedStatic = null; - $isStaticFunction = false; - $sourceFile = $rf->getFileName(); - if (\str_contains($sourceFile, 'eval()\'d')) { - throw new RuntimeException("Can't serialize a closure that was generated with eval()"); - } - if (isset(self::$tokenCache[$sourceFile])) { - $tokens = self::$tokenCache[$sourceFile]; - } else { - $tokens = self::$tokenCache[$sourceFile] = PhpToken::tokenize(file_get_contents($sourceFile)); - } - - $capture = false; - $capturedTokens = []; - $stackDepth = 0; - $stack = []; - foreach ($tokens as $idx => $token) { - if (!$capture) { - if ($token->line === $rf->getStartLine()) { - if ($token->id === \T_STATIC && $tokens[$idx + 2]?->id === \T_FUNCTION) { - $capture = true; - $isStaticFunction = true; - } elseif ($token->id === T_FUNCTION || $token->id === \T_FN) { - $capture = true; - } else { - continue; - } - } else { - continue; - } - } - if (!$token->isIgnorable()) { - if ($stackDepth === 0 && \str_contains(",)}];", $token->text)) { - break; - } - if (!$usedStatic && ($token->text === 'self' || $token->text === 'static' || $token->text === 'parent')) { - $usedStatic = true; - } - if (!$usedThis && $token->id === T_VARIABLE && ($token->text === '$this')) { - $usedThis = true; - } - } - $capturedTokens[] = $token; - if ($token->text === '{') { - $stack[$stackDepth++] = '}'; - } elseif ($token->text === '(') { - $stack[$stackDepth++] = ')'; - } elseif ($token->text === '[') { - $stack[$stackDepth++] = ']'; - } elseif ($stackDepth > 0 && $stack[$stackDepth - 1] === $token->text) { - --$stackDepth; - if ($stackDepth === 0 && $token->text === '}') { - if ($token->line !== $rf->getEndLine() && $token->line === $rf->getStartLine()) { - $capture = false; - $capturedTokens = []; - } else { - break; - } - } - } - } - $codes = []; - foreach ($capturedTokens as $token) { - $codes[] = $token->text; - } - - self::$functionCache[$hash] = [ - 'code' => \implode('', $codes), - 'usedThis' => $usedThis, - 'usedStatic' => $usedStatic, - ]; - - return self::$functionCache[$hash]['code']; - } - - /** - * Find the first token on the specified line. - * - * @param PhpToken[] $tokens The array of PhpToken objects - * @param int $line The line number to search for - * @return int The offset in the tokens array where the line starts - * @throws RuntimeException If the line number is not found in the tokens array - */ - private static function findLineOffset(array &$tokens, int $line): int - { - $low = 0; - $high = count($tokens) - 1; - - while ($low <= $high) { - $mid = ($low + $high) >> 1; // Equivalent to (int)(($low + $high) / 2) but faster - $token = $tokens[$mid]; - - if ($token->line > $line) { - $high = $mid - 1; - } elseif ($token->line < $line) { - $low = $mid + 1; - } else { - // We've found a token on the desired line, now search backwards to find the first token on that line. - while ($mid > 0 && $tokens[$mid - 1]->line === $line) { - $mid--; - } - return $mid; - } - } - - throw new RuntimeException("Token offset for line {$line} could not be found."); - } } diff --git a/tests/Unit/ReflectionClosureTest.php b/tests/Unit/ReflectionClosureTest.php new file mode 100644 index 0000000..a642bc1 --- /dev/null +++ b/tests/Unit/ReflectionClosureTest.php @@ -0,0 +1,175 @@ + (new ReflectionClass(ReflectionClosure::class)) + ->getProperty('functionCache')->setValue(null, []) +); + +test('extracts the code from', function (Closure $input, string $expected): void { + $reflectionClosure = new ReflectionClosure($input); + + $actual = $reflectionClosure->getCode(); + + expect($actual)->toBe($expected); +}) + ->with(function (): Generator { + $input = function (string $param): string { + return $param; + }; + yield 'a function' => [ + $input, + 'function (string $param): string { + return $param; + }', + ]; + + $input = function (float $param): array { + return [(int) $param => [(string) $param], ((int) $param) + 1 => function () {}]; + }; + yield 'a function containing parentheses, brackets and braces' => [ + $input, + 'function (float $param): array { + return [(int) $param => [(string) $param], ((int) $param) + 1 => function () {}]; + }', + ]; + + $input = fn(string $param): string => $param; + yield 'an arrow function' => [ + $input, + 'fn(string $param): string => $param', + ]; + + $input = fn(string $param): string => + $param; + yield 'a multi-line arrow function' => [ + $input, + 'fn(string $param): string => + $param', + ]; + + $input = static function (string $param): string { + return $param; + }; + yield 'a static function' => [ + $input, + 'static function (string $param): string { + return $param; + }', + ]; + + $input = static fn(string $param): string => $param; + yield 'a static arrow function' => [ + $input, + 'static fn(string $param): string => $param', + ]; + + $input = static fn(string $param): string => + $param; + yield 'a static multi-line arrow function' => [ + $input, + 'static fn(string $param): string => + $param', + ]; + + [function ($wrong1) {}, $input = function (string $param): string { + return $param; + }, function ($wrong2) {}]; + yield 'a function surrounded by other function declarations' => [ + $input, + 'function (string $param): string { + return $param; + }', + ]; + + [function ($wrong1) {}, $input = static function (string $param): string { + return $param; + }, function ($wrong2) {}]; + yield 'a static function surrounded by other function declarations' => [ + $input, + 'static function (string $param): string { + return $param; + }', + ]; + + [function ($wrong1) {}, $input = fn(string $param): string + => $param, function ($wrong2) {}]; + yield 'an arrow function surrounded by other function declarations' => [ + $input, + 'fn(string $param): string + => $param', + ]; + + [function ($wrong1) {}, $input = static fn(string $param): string + => $param, function ($wrong2) {}]; + yield 'a static arrow function surrounded by other function declarations' => [ + $input, + 'static fn(string $param): string + => $param', + ]; + + // TODO: A function surrounded by other function declarations on a single line + // TODO: An arrow function surrounded by other function declarations on a single line + // TODO: A static function surrounded by other function declarations on a single line + // TODO: A static arrow function surrounded by other function declarations on a single line + // TODO: More than one ignorable token between `T_STATIC` and `T_FUNCTION` + // TODO: More than one ignorable token between `T_STATIC` and `T_FN` + // TODO: `T_STATIC` on a other line than `T_FUNCTION` + // TODO: `T_STATIC` on a other line than `T_FN` + }); + +test('indicates that `$this` was used', function (): void { + $input = function (): object { + return $this; + }; + $reflectionClosure = new ReflectionClosure($input); + + $actual = $reflectionClosure->usedThis(); + + expect($actual)->toBeTrue(); +}); + +test('indicates that `static` was used', function (): void { + $input = function (): int { + static $a = 5; + return $a; + }; + $reflectionClosure = new ReflectionClosure($input); + + $actual = $reflectionClosure->usedStatic(); + + expect($actual)->toBeTrue(); +}); + +test('fails when trying to reflect upon a function created with `eval`', function (): void { + /** @var Closure $input */ + eval('$input = static function (string $param): mixed { + $param .= \'Text\'; + return $param; + };'); + + new ReflectionClosure($input); +}) + ->throws(RuntimeException::class); + +test('returns placeholder information for native functions', function (): void { + $input = printf(...); + $reflectionClosure = new ReflectionClosure($input); + + expect($reflectionClosure->getCode())->toBe('/* native code */'); + expect($reflectionClosure->usedThis())->toBeFalse(); + expect($reflectionClosure->usedStatic())->toBeFalse(); +});