diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a2087..9a15199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ CHANGELOG --------- +### v4.2.0, 2025.01.07 + +#### Changes + +- Added support for anonymous classes (also supports closures bound to anonymous classes) +- Fixed closure scope for some edge cases +- Improved parsers + +#### Internal changes + +Added classes + +- `AbastractInfo` +- `AbstractParser` +- `AnonymousClassInfo` +- `AnonymousClassParser` +- `ReflectionClass` +- `CodeStream` + +Removed + +- `ClosureStream` (replaced by `CodeStream`) +- `ClassInfo` (replace by `ReflectionClass`) + ### v4.1.0, 2025.01.05 #### Changes diff --git a/README.md b/README.md index 68b2f5b..f539849 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,28 @@ Serialize closures, serialize anything ```php use function Opis\Closure\{serialize, unserialize}; -$serialized = serialize(fn() => "hello!"); +$serialized = serialize(fn() => "hello from closure!"); $greet = unserialize($serialized); -echo $greet(); // hello +echo $greet(); // hello from closure! +``` + +> [!IMPORTANT] +> Starting with version 4.2, **Opis Closure** supports serialization of anonymous classes. + +```php +use function Opis\Closure\{serialize, unserialize}; + +$serialized = serialize(new class("hello from anonymous class!") { + public function __construct(private string $message) {} + + public function greet(): string { + return $this->message; + } +}); + +$object = unserialize($serialized); +echo $object->greet(); // hello from anonymous class! ``` _A full rewrite was necessary to keep this project compatible with the PHP's new features, such as attributes, enums, @@ -59,7 +77,7 @@ Or you could directly reference it into your `composer.json` file as a dependenc ```json { "require": { - "opis/closure": "^4.1" + "opis/closure": "^4.2" } } ``` diff --git a/src/AbstractInfo.php b/src/AbstractInfo.php new file mode 100644 index 0000000..80dc61f --- /dev/null +++ b/src/AbstractInfo.php @@ -0,0 +1,119 @@ +header = $header; + $this->body = $body; + } + + abstract public function getFactoryPHP(bool $phpTag = true): string; + abstract public function getIncludePHP(bool $phpTag = true): string; + + /** + * Unique info key + * @return string + */ + final public function key(): string + { + if (!isset($this->key)) { + $this->key = self::createKey($this->header, $this->body); + // save it to cache + self::$cache[$this->key] = $this; + } + return $this->key; + } + + final public function url(): string + { + return CodeStream::STREAM_PROTO . '://' . static::name() . '/' . $this->key(); + } + + public function __serialize(): array + { + $data = [ + "key" => $this->key() + ]; + if ($this->header) { + $data["header"] = $this->header; + } + $data["body"] = $this->body; + return $data; + } + + public function __unserialize(array $data): void + { + $key = $this->key = $data["key"]; + // in v4.0.0 header was named imports, handle that case too + $this->header = $data["header"] ?? $data["imports"] ?? ""; + $this->body = $data["body"]; + + // populate cache on deserialization + if ($key && !isset(self::$cache[$key])) { + // save it to cache + self::$cache[$key] = $this; + } + } + + /** + * @var static[] + */ + private static array $cache = []; + + /** + * @var \ReflectionClass[] + */ + private static array $reflector = []; + + /** + * @return string Unique short name + */ + abstract public static function name(): string; + + /** + * Loads info from cache or rebuilds from serialized data + * @param array $data + * @return static + */ + final public static function load(array $data): static + { + $key = $data["key"] ?? null; + if ($key && isset(self::$cache[$key])) { + // found in cache + return self::$cache[$key]; + } + + /** @var static $obj */ + $obj = (self::$reflector[static::name()] ??= new \ReflectionClass(static::class))->newInstanceWithoutConstructor(); + + $obj->__unserialize($data); + + return $obj; + } + + final public static function clear(): void + { + self::$cache = []; + } + + final public static function resolve(string $key): ?static + { + return self::$cache[$key] ?? null; + } + + final public static function createKey(string $header, string $body): string + { + // yes, there was a mistake in params order, keep the body first + $code = "$body\n$header"; + return $code === "\n" ? "" : md5($code); + } +} \ No newline at end of file diff --git a/src/AbstractParser.php b/src/AbstractParser.php new file mode 100644 index 0000000..eadb2d8 --- /dev/null +++ b/src/AbstractParser.php @@ -0,0 +1,363 @@ +count = count($tokens); + } + + protected function between(int $index, int $end): string + { + $hint = ''; + $code = ''; + $tokens = $this->tokens; + $use_hints = $this->aliases !== null; + + while ($index <= $end) { + $token = $tokens[$index++]; + $code .= is_array($token) ? $token[1] : $token; + + if ($use_hints) { + switch ($token[0]) { + case T_STRING: + case T_NS_SEPARATOR: + case T_NAME_QUALIFIED: + case T_NAME_FULLY_QUALIFIED: + $hint .= $token[1]; + break; + case T_WHITESPACE: + case T_COMMENT: + case T_DOC_COMMENT: + // ignore whitespace and comments + break; + default: + if ($hint !== '') { + $this->addHint($hint); + $hint = ''; + } + break; + } + } + } + + if ($use_hints && $hint !== '') { + $this->addHint($hint); + } + + return $code; + } + + /** + * @param int $index + * @param array $token_types + * @param bool $match Pass false to skip, pass true to match first + * @param int $maxLine + * @return int + */ + protected function walk(int $index, array $token_types, bool $match = false, int $maxLine = PHP_INT_MAX): int + { + $count = $this->count; + $tokens = $this->tokens; + + do { + $is_arr = is_array($tokens[$index]); + if ( + $index >= $count || // too big + ($is_arr && $tokens[$index][2] > $maxLine) // past max line + ) { + return -1; + } + if ($match === in_array($is_arr ? $tokens[$index][0] : $tokens[$index], $token_types, true)) { + return $index; + } + $index++; + } while (true); + } + + /** + * Add import hint + * @param string $hint + * @return bool True if hint was added + */ + protected function addHint(string $hint): bool + { + if (!$hint || $hint[0] == '\\') { + // Ignore empty or absolute + return false; + } + + $key = strtolower($hint); + + if (isset($this->hints[$key]) || in_array($key, self::$BUILTIN_TYPES)) { + return false; + } + + $this->hints[$key] = $hint; + + return true; + } + + /** + * Get namespace and formatted imports + * @return string + */ + protected function getHeader(): string + { + $ns = $this->ns; + + if ($this->aliases || $this->hints) { + $code = self::formatImports($this->aliases, $this->hints, $ns); + } else { + $code = ""; + } + + if ($ns) { + return "namespace {$ns};" . ($code ? "\n" : "") . $code; + } + + return $code; + } + + abstract protected function getBody(): string; + + abstract public function getInfo(): ?AbstractInfo; + + //////////////////////////////////////////////////// + + /** + * @var AbstractInfo[] + */ + private static array $cache = []; + + private static array $fileCache = []; + + final public static function clear(bool $include_file_cache = false): void + { + self::$cache = []; + if ($include_file_cache) { + self::$fileCache = []; + } + } + + abstract public static function parse($reflector): ?AbstractInfo; + + abstract protected static function create( + $reflector, + string $ns, + array $fileInfo, + ?array $aliases + ): static; + + protected static function resolve( + \ReflectionClass|\ReflectionFunction $reflector, + string $prefix + ): ?AbstractInfo + { + // Get file name + $file = $reflector->getFileName(); + + // Check if file name is present + if (!$file) { + return null; + } + + // Try already deserialized + // closure://... + if ($fromStream = CodeStream::info($file)) { + return $fromStream; + } + + // Get file key + $fileKey = md5($file); + + // Get line bounds + $startLine = $reflector->getStartLine(); + $endLine = $reflector->getEndLine(); + + // compute top-level cache key + $cacheKey = "{$prefix}/{$fileKey}/{$startLine}/{$endLine}"; + + // check info cache + if (array_key_exists($cacheKey, self::$cache)) { + return self::$cache[$cacheKey]; + } + + // check file cache + if (!array_key_exists($fileKey, self::$fileCache)) { + self::$fileCache[$fileKey] = TokenizedFileInfo::getInfo($file); + } + + $fileInfo = self::$fileCache[$fileKey]; + if ($fileInfo === null) { + return self::$cache[$cacheKey] = null; + } + + $ns = ''; + $aliases = null; + if ($fileInfo['namespaces']) { + if ($info = self::findNamespaceAliases($fileInfo['namespaces'], $startLine)) { + $ns = trim($info['ns'] ?? '', '\\'); + $aliases = $info['use'] ?? null; + } + } + + // cache parsed result + return self::$cache[$cacheKey] = static::create($reflector, $ns, $fileInfo, $aliases)->getInfo(); + } + + private static function findNamespaceAliases(array $namespaces, int $startLine): ?array + { + foreach ($namespaces as $info) { + if ($startLine >= $info['start'] && $startLine <= $info['end']) { + return $info; + } + } + return null; + } + + ///////////////////////////////////////// + + private const FORMAT_IMPORTS_MAP = [ + 'class' => 'use ', + 'func' => 'use function ', + 'const' => 'use const ', + ]; + + private static function formatImports(array $alias, array $hints, string $ns = ""): string + { + if ($ns && $ns[0] !== '\\') { + $ns = '\\' . $ns; + } + + $use = []; + + foreach ($hints as $hint => $hintValue) { + if (($pos = strpos($hint, '\\')) !== false) { + // Relative + $hint = substr($hint, 0, $pos); + $hintValue = substr($hintValue, 0, $pos); + } + + foreach ($alias as $type => $values) { + if (!isset($values[$hint])) { + continue; + } + + if (strcasecmp($ns . '\\' . $hint, $values[$hint]) === 0) { + // Skip redundant import + continue; + } + + $use[$type][$hintValue] = $values[$hint]; + } + } + + if (!$use) { + return ''; + } + + $code = ''; + + foreach (self::FORMAT_IMPORTS_MAP as $key => $prefix) { + if (!isset($use[$key])) { + continue; + } + if ($add = self::formatUse($prefix, $use[$key])) { + if ($code) { + $code .= "\n"; + } + $code .= $add; + } + } + + return $code; + } + + private static function formatUse(string $prefix, ?array $items): string + { + if (!$items) { + return ''; + } + + foreach ($items as $alias => $full) { + if (strcasecmp('\\' . $alias, substr($full, 0 - strlen($alias) - 1)) === 0) { + // Same name as alias, do not use as + $items[$alias] = trim($full, '\\'); + } else { + $items[$alias] = trim($full, '\\') . ' as ' . $alias; + } + } + + sort($items); + + return $prefix . implode(",\n" . str_repeat(' ', strlen($prefix)), $items) . ";"; + } + + private static array $BUILTIN_TYPES; + + private static function getBuiltInTypes(): array + { + // PHP 8 + + $types = [ + 'bool', 'int', 'float', 'string', 'array', + 'object', 'iterable', 'callable', 'void', 'mixed', + 'self', 'parent', 'static', + 'false', 'null', + ]; + + if (PHP_MINOR_VERSION >= 1) { + $types[] = 'never'; + } + + if (PHP_MINOR_VERSION >= 2) { + $types[] = 'true'; + } + + return $types; + } + + final public static function init(): void + { + self::$BUILTIN_TYPES ??= self::getBuiltInTypes(); + } +} \ No newline at end of file diff --git a/src/AnonymousClassInfo.php b/src/AnonymousClassInfo.php new file mode 100644 index 0000000..6bc7878 --- /dev/null +++ b/src/AnonymousClassInfo.php @@ -0,0 +1,103 @@ +ns = trim($ns, '\\'); + } + + public function __serialize(): array + { + $data = parent::__serialize(); + if ($this->ns) { + $data["ns"] = $this->ns; + } + return $data; + } + + public function __unserialize(array $data): void + { + $this->ns = $data["ns"] ?? ""; + parent::__unserialize($data); + } + + /** + * Loads the class if not already loaded + * @return string Name of the loaded class + */ + public function loadClass(): string + { + $class = $this->fullClassName(); + + if (!$this->loaded) { + if (!class_exists($class, false)) { + // include the class + CodeStream::include($this); + } + $this->loaded = true; + } + + return $class; + } + + public function fullClassName(): string + { + $class = ReflectionClass::ANONYMOUS_CLASS_PREFIX . $this->key(); + return $this->ns ? "\\{$this->ns}\\{$class}" : $class; + } + + public function getFactoryPHP(bool $phpTag = true): string + { + return $this->getPHP($phpTag, true); + } + + public function getIncludePHP(bool $phpTag = true): string + { + return $this->getPHP($phpTag, false); + } + + private function getPHP(bool $phpTag, bool $check): string + { + $code = $phpTag ? 'header) { + $code .= $this->header . "\n"; + } + + // name without namespace + $class = ReflectionClass::ANONYMOUS_CLASS_PREFIX . $this->key(); + + if ($check) { + $code .= "if (!\class_exists({$class}::class, false)):\n"; + } + + $code .= preg_replace('/' . self::PLACEHOLDER . '/', $class, $this->body, 1); + + if ($check) { + $code .= "\nendif;"; + } + + return $code; + } + + public static function name(): string + { + return "an"; + } +} diff --git a/src/AnonymousClassParser.php b/src/AnonymousClassParser.php new file mode 100644 index 0000000..86931b6 --- /dev/null +++ b/src/AnonymousClassParser.php @@ -0,0 +1,172 @@ +index; + $tokens = $this->tokens; + + for ($start_index = $index - 1; $start_index >= 0; $start_index--) { + if ($tokens[$start_index][0] === T_NEW) { + break; + } + } + + $start_index++; + // skip whitespace after T_NEW + while ($tokens[$start_index][0] === T_WHITESPACE) { + $start_index++; + } + + $code = $this->between($start_index, $index - 1); + if ($code) { + // put class on a new line + $code .= "\n"; + } + $code .= "class " . AnonymousClassInfo::PLACEHOLDER; + + // skip T_CLASS + $index++; + if ($tokens[$index][0] !== T_WHITESPACE) { + // add a space if necessary + $code .= " "; + } + + $next_index = $this->walk($index, ['(', '{'], true, $this->reflector->getEndLine()); + + $code .= $this->between($index, $next_index - 1); + + $endsInWs = $tokens[$next_index - 1][0] === T_WHITESPACE; + $last = $this->last; + + if ($tokens[$next_index] === '(') { + $open = 1; + while (++$next_index < $last) { + switch ($tokens[$next_index]) { + case '(': + $open++; + break; + case ')': + if (!--$open) { + $next_index++; + break 2; + } + break; + } + } + // skip ws + if ($endsInWs) { + while ($tokens[$next_index][0] === T_WHITESPACE) { + $next_index++; + } + } + } + + $code .= $this->between($next_index, $last); + + return $code; + } + + public function getInfo(): ?AnonymousClassInfo + { + if (!$this->findClassIndex()) { + return null; + } + + // we have to load body first + $body = $this->getBody(); + // then we have can get the header + $header = $this->getHeader(); + + return new AnonymousClassInfo($header, $body, $this->ns); + } + + private function findClassIndex(): bool + { + if (!$this->anonymous) { + return false; + } + + // line of T_CLASS + $startLine = $this->reflector->getStartLine(); + $tokens = $this->tokens; + + $last = -1; + foreach ($this->anonymous as $range) { + // search for T_CLASS + $index = -1; + $i = $range[0]; + while ($i > $last) { + if ($tokens[$i][0] === T_CLASS) { + $index = $i; + break; + } + $i--; + } + + if ($index < 0) { + $last = $range[0]; + continue; + } + + // we have a T_CLASS, we must check the line + if ($tokens[$index][2] >= $startLine) { + // TODO: we can extract the position of the class in the same line using the number after $ + // SomeClassName@anonymous file:7$0 + $this->index = $index; + $this->last = $range[1]; + return true; + } + } + + return false; + } + + /** + * @param ReflectionClass $reflector + * @return AnonymousClassInfo|null + */ + public static function parse($reflector): ?AnonymousClassInfo + { + if (is_string($reflector)) { + $reflector = ReflectionClass::get($reflector); + } + + if ($reflector->isInternal() || !$reflector->isAnonymousLike()) { + return null; + } + + return self::resolve($reflector, AnonymousClassInfo::name()); + } + + /** + * @param ReflectionClass $reflector + * @param string $ns + * @param array $fileInfo + * @param array|null $aliases + * @return static + */ + protected static function create($reflector, string $ns, array $fileInfo, ?array $aliases): static + { + return new self($reflector, $ns, $aliases, $fileInfo["tokens"], $fileInfo["anonymous"]); + } +} \ No newline at end of file diff --git a/src/Box.php b/src/Box.php index 9b23f65..133feab 100644 --- a/src/Box.php +++ b/src/Box.php @@ -11,6 +11,7 @@ final class Box public const TYPE_CLOSURE = 1; public const TYPE_CALLABLE = 2; public const TYPE_OBJECT = 3; + public const TYPE_ANONYMOUS_CLASS = 4; public function __construct( public int $type, diff --git a/src/ClassInfo.php b/src/ClassInfo.php deleted file mode 100644 index 34d568a..0000000 --- a/src/ClassInfo.php +++ /dev/null @@ -1,73 +0,0 @@ -reflection = new ReflectionClass($className); - $this->box = empty($reflection->getAttributes(Attribute\PreventBoxing::class)); - $this->hasMagicSerialize = $reflection->hasMethod("__serialize"); - $this->hasMagicUnserialize = $reflection->hasMethod("__unserialize"); - } - - public function className(): string - { - return $this->reflection->name; - } - - public static function get(string $class): self - { - return self::$cache[$class] ??= new self($class); - } - - public static function clear(): void - { - self::$cache = []; - } - - public static function isInternal(object|string $object): bool - { - return self::get(is_string($object) ? $object : get_class($object))->reflection->isInternal(); - } - - public static function isEnum(mixed $value): bool - { - // enums were added in php 8.1 - self::$enumExists ??= interface_exists(UnitEnum::class, false); - return self::$enumExists && ($value instanceof UnitEnum); - } - - public static function refId(mixed &$reference): ?string - { - return ReflectionReference::fromArrayElement([&$reference], 0)?->getId(); - } -} diff --git a/src/ClosureInfo.php b/src/ClosureInfo.php index 09da9e9..70a6e9c 100644 --- a/src/ClosureInfo.php +++ b/src/ClosureInfo.php @@ -5,62 +5,29 @@ use Closure; #[Attribute\PreventBoxing] -final class ClosureInfo +final class ClosureInfo extends AbstractInfo { public const FLAG_IS_SHORT = 1; public const FLAG_IS_STATIC = 2; public const FLAG_HAS_THIS = 4; public const FLAG_HAS_SCOPE = 8; - private ?string $key = null; + public ?array $use; - private ?Closure $factory = null; + public int $flags; - /** - * @var ClosureInfo[] Cache by key - */ - private static array $cache = []; + private ?Closure $factory = null; public function __construct( - /** - * Function imports including namespace - * @var string - */ - private string $header, - - /** - * Function body - * @var string - */ - private string $body, - - /** - * Variable names from use() - * @var string[]|null - */ - public ?array $use = null, - - /** - * Closure properties - * @var int - */ - public int $flags = 0, + string $header, + string $body, + ?array $use = null, + int $flags = 0, ) { - } - - /** - * Unique key for this function info - * @return string - */ - public function key(): string - { - if (!isset($this->key)) { - $this->key = self::createKey($this->header, $this->body); - // save it to cache - self::$cache[$this->key] = $this; - } - return $this->key; + parent::__construct($header, $body); + $this->use = $use; + $this->flags = $flags; } /** @@ -109,7 +76,8 @@ public function getClosure(?array &$vars = null, ?object $thisObj = null, ?strin */ public function getFactory(?object $thisObj, ?string $scope = null): Closure { - $factory = ($this->factory ??= ClosureStream::factory($this)); + /** @var Closure $factory */ + $factory = ($this->factory ??= CodeStream::include($this)); if ($thisObj && $this->isStatic()) { // closure is static, we cannot bind @@ -122,13 +90,30 @@ public function getFactory(?object $thisObj, ?string $scope = null): Closure } if ($thisObj) { - if (ClassInfo::isInternal($thisObj)) { + $reflector = ReflectionClass::get($thisObj); + + if ($reflector->isInternal()) { + // we cannot bind to internal objects return $factory->bindTo($thisObj); } + + if ($scope && $scope !== $reflector->name) { + // we have a different scope than the object + // this usually happens if the closure is bound + // in a super class and has access to private members of the super + return $factory->bindTo($thisObj, $scope); + } + + // use the same object as scope return $factory->bindTo($thisObj, $thisObj); } - if ($scope && $scope !== "static" && $this->hasScope() && !ClassInfo::isInternal($scope)) { + if ( + $scope && + $scope !== "static" && + $this->hasScope() && + !ReflectionClass::get($scope)->isInternal() + ) { return $factory->bindTo(null, $scope); } @@ -166,11 +151,7 @@ public function getIncludePHP(bool $phpTag = true): string public function __serialize(): array { - $data = ['key' => $this->key()]; - if ($this->header) { - $data['header'] = $this->header; - } - $data['body'] = $this->body; + $data = parent::__serialize(); if ($this->use) { $data['use'] = $this->use; } @@ -182,20 +163,14 @@ public function __serialize(): array public function __unserialize(array $data): void { - $this->key = $data['key'] ?? null; - $this->header = $data['header'] ?? $data['imports'] ?? ''; - $this->body = $data['body']; $this->use = $data['use'] ?? null; $this->flags = $data['flags'] ?? 0; - if ($this->key && !isset(self::$cache[$this->key])) { - // save it to cache - self::$cache[$this->key] = $this; - } + parent::__unserialize($data); } - public static function createKey(string $body, ?string $header = null): string + public static function name(): string { - return md5(($header ?? "") . "\n" . $body); + return "fn"; } public static function flags( @@ -225,18 +200,4 @@ public static function flags( return $flags; } - - public static function resolve(string $key): ?ClosureInfo - { - return self::$cache[$key] ?? null; - } - - /** - * Clears cache - * @return void - */ - public static function clear(): void - { - self::$cache = []; - } } \ No newline at end of file diff --git a/src/ClosureParser.php b/src/ClosureParser.php index 87ac0f6..473223e 100644 --- a/src/ClosureParser.php +++ b/src/ClosureParser.php @@ -2,62 +2,40 @@ namespace Opis\Closure; -use ReflectionFunction; - /** * @internal */ -final class ClosureParser +final class ClosureParser extends AbstractParser { - private const SKIP_WHITESPACE_AND_COMMENTS = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]; - private const MATCH_CLOSURE = [T_FN, T_FUNCTION]; private const ACCESS_PROP = [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON]; - /** - * @var array Transformed file tokens cache - */ - private static array $globalFileCache = []; - - /** - * @var array Closure info cache by file/stat-line/end-line - */ - private static array $globalInfoCache = []; - - /** - * @var string[] PHP types - */ - private static ?array $BUILTIN_TYPES = null; - - private int $count; - private int $index = -1; + private const SKIP_WHITESPACE_AND_COMMENTS = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]; private bool $isShort = false; private bool $isStatic = false; private bool $scopeRef = false; private bool $thisRef = false; - private array $use = []; - private array $hints = []; private function __construct( - private ReflectionFunction $reflector, - private array $tokens, - private string $ns, - private ?array $aliases, - private array $anonymous + private \ReflectionFunction $reflector, + string $ns, + ?array $aliases, + array $tokens, + array $anonymous ) { - $this->count = count($tokens); + parent::__construct($ns, $aliases, $tokens, $anonymous); } /** * @return ClosureInfo|null */ - private function info(): ?ClosureInfo + public function getInfo(): ?ClosureInfo { - $this->index = $this->functionIndex(); + $this->index = $this->findFunctionIndex(); if ($this->index < 0) { return null; @@ -66,13 +44,13 @@ private function info(): ?ClosureInfo $this->filterAnonymous($this->index); // we must get the code first - $code = $this->sourceCode(); + $body = $this->getBody(); // only then we can process the imports - $imports = $this->imports(); + $header = $this->getHeader(); return new ClosureInfo( - $imports, - $code, + $header, + $body, $this->use ?: null, ClosureInfo::flags( $this->isShort, @@ -101,7 +79,7 @@ private function filterAnonymous(int $index): void /** * @return int */ - private function functionIndex(): int + private function findFunctionIndex(): int { $startLine = $this->reflector->getStartLine(); $tokens = $this->tokens; @@ -179,7 +157,7 @@ private function functionIndex(): int /** * @return string */ - private function sourceCode(): string + protected function getBody(): string { $tokens = $this->tokens; $count = $this->count; @@ -205,10 +183,8 @@ private function sourceCode(): string case T_DOC_COMMENT: continue 2; case ']': - if (T_ATTRIBUTE > 0) { - $balance--; - continue 2; - } + $balance--; + continue 2; } } else { switch ($tokens[$start_index][0]) { @@ -326,29 +302,87 @@ private function sourceCode(): string return $code; } - /** - * @returns string - */ - private function imports(): string + private function handleBalanceToken(int $index): void { - $code = ""; - $ns = $this->ns; + if ($this->thisRef && $this->scopeRef) { + // we already have these + return; + } - if ($this->aliases || $this->hints) { - $code = self::formatImports($this->aliases, $this->hints, $ns); + $check = false; + $checkThis = false; + $isStatic = false; + $isParent = false; + + $token = $this->tokens[$index]; + + if ($token[0] === T_STATIC) { + $check = $isStatic = true; + } elseif ($token[0] === T_VARIABLE) { + $check = $checkThis = strcasecmp($token[1], '$this') === 0; + } elseif ($token[0] === T_STRING) { + if (strcasecmp($token[1], 'self') === 0) { + $check = !$this->nextIs($index, self::ACCESS_PROP, true); + } elseif (strcasecmp($token[1], 'parent') === 0) { + $check = $isParent = !$this->nextIs($index, self::ACCESS_PROP, true); + $checkThis = !$this->isStatic; + } } - if ($ns) { - return "namespace {$ns};" . ($code ? "\n" : "") . $code; + if (!$check) { + // nothing to check + return; } - return $code; + if ($checkThis) { + if ($this->thisRef) { + if ($isParent) { + $this->scopeRef = true; + } + // already has $this + return; + } + } elseif ($this->scopeRef) { + // already has scope + return; + } + + foreach ($this->anonymous as $anonymous) { + if ($index >= $anonymous[0] && $index <= $anonymous[1]) { + // this token is inside anonymous class body, ignore it + return; + } + } + + if ($checkThis) { + $this->thisRef = true; + if ($isParent) { + $this->scopeRef = true; + } + return; + } + + if ($isStatic) { + while ($index < $this->count) { + switch ($this->tokens[++$index][0]) { + case T_WHITESPACE: + case T_COMMENT: + case T_DOC_COMMENT: + continue 2; + case T_VARIABLE: + // static variable + return; + } + break; + } + } + + $this->scopeRef = true; + if ($isParent) { + $this->thisRef = true; + } } - /** - * @param int $end_line - * @return string - */ private function balanceExpression(int $end_line): string { $tokens = $this->tokens; @@ -459,7 +493,7 @@ private function balanceExpression(int $end_line): string } if ($is_array) { - $this->checkSpecialToken($index - 1); + $this->handleBalanceToken($index - 1); $code .= $token[1]; } else { $code .= $token; @@ -479,12 +513,6 @@ private function balanceExpression(int $end_line): string return $code; } - /** - * @param $start - * @param $end - * @param int $open - * @return string - */ private function balance($start, $end, int $open = 0): string { $tokens = $this->tokens; @@ -513,7 +541,7 @@ private function balance($start, $end, int $open = 0): string break; } } elseif ($is_array) { - $this->checkSpecialToken($index - 1); + $this->handleBalanceToken($index - 1); } if ($use_hints) { @@ -592,79 +620,6 @@ private function balance($start, $end, int $open = 0): string return $code; } - /** - * @param int $index - * @param int $end - * @return string - */ - private function between(int $index, int $end): string - { - $hint = ''; - $code = ''; - $tokens = $this->tokens; - $use_hints = $this->aliases !== null; - - while ($index <= $end) { - $token = $tokens[$index++]; - $code .= is_array($token) ? $token[1] : $token; - - if ($use_hints) { - switch ($token[0]) { - case T_STRING: - case T_NS_SEPARATOR: - case T_NAME_QUALIFIED: - case T_NAME_FULLY_QUALIFIED: - $hint .= $token[1]; - break; - case T_WHITESPACE: - case T_COMMENT: - case T_DOC_COMMENT: - // ignore whitespace and comments - break; - default: - if ($hint !== '') { - $this->addHint($hint); - $hint = ''; - } - break; - } - } - } - - if ($use_hints && $hint !== '') { - $this->addHint($hint); - } - - return $code; - } - - /** - * @param int $index - * @param array $token_types - * @param bool $match Pass false to skip, pass true to match first - * @param int $maxLine - * @return int - */ - private function walk(int $index, array $token_types, bool $match = false, int $maxLine = PHP_INT_MAX): int - { - $count = $this->count; - $tokens = $this->tokens; - - do { - $is_arr = is_array($tokens[$index]); - if ( - $index >= $count || // too big - ($is_arr && $tokens[$index][2] > $maxLine) // past max line - ) { - return -1; - } - if ($match === in_array($is_arr ? $tokens[$index][0] : $tokens[$index], $token_types, true)) { - return $index; - } - $index++; - } while (true); - } - private function nextIs(int $index, array $token_types, bool $reverse = false): bool { $count = $this->count; @@ -686,311 +641,34 @@ private function nextIs(int $index, array $token_types, bool $reverse = false): return false; } - /** - * @param string $hint - * @return bool - */ - private function addHint(string $hint): bool - { - if (!$hint || $hint[0] == '\\') { - // Ignore empty or absolute - return false; - } - - $key = strtolower($hint); - - if (isset($this->hints[$key]) || in_array($key, self::$BUILTIN_TYPES)) { - return false; - } - - $this->hints[$key] = $hint; - - return true; - } - - private function checkSpecialToken(int $index): void - { - if ($this->thisRef && $this->scopeRef) { - // we already have these - return; - } - - $check = false; - $checkThis = false; - $isStatic = false; - $isParent = false; - - $token = $this->tokens[$index]; - - if ($token[0] === T_STATIC) { - $check = $isStatic = true; - } elseif ($token[0] === T_VARIABLE) { - $check = $checkThis = strcasecmp($token[1], '$this') === 0; - } elseif ($token[0] === T_STRING) { - if (strcasecmp($token[1], 'self') === 0) { - $check = !$this->nextIs($index, self::ACCESS_PROP, true); - } elseif (strcasecmp($token[1], 'parent') === 0) { - $check = $isParent = !$this->nextIs($index, self::ACCESS_PROP, true); - $checkThis = !$this->isStatic; - } - } - - if (!$check) { - // nothing to check - return; - } - - if ($checkThis) { - if ($this->thisRef) { - if ($isParent) { - $this->scopeRef = true; - } - // already has $this - return; - } - } elseif ($this->scopeRef) { - // already has scope - return; - } - - foreach ($this->anonymous as $anonymous) { - if ($index >= $anonymous[0] && $index <= $anonymous[1]) { - // this token is inside anonymous class body, ignore it - return; - } - } - - if ($checkThis) { - $this->thisRef = true; - if ($isParent) { - $this->scopeRef = true; - } - return; - } - - if ($isStatic) { - while ($index < $this->count) { - switch ($this->tokens[++$index][0]) { - case T_WHITESPACE: - case T_COMMENT: - case T_DOC_COMMENT: - continue 2; - case T_VARIABLE: - // static variable - return; - } - break; - } - } - - $this->scopeRef = true; - if ($isParent) { - $this->thisRef = true; - } - } - - /** - * @param array $namespaces - * @param int $startLine - * @return array|null - */ - private static function findNamespaceAliases(array $namespaces, int $startLine): ?array - { - foreach ($namespaces as $info) { - if ($startLine >= $info['start'] && $startLine <= $info['end']) { - return $info; - } - } - - return null; - } - - /** - * @param string $prefix - * @param array|null $items - * @return string - */ - private static function formatUse(string $prefix, ?array $items): string - { - if (!$items) { - return ''; - } - - foreach ($items as $alias => $full) { - if (strcasecmp('\\' . $alias, substr($full, 0 - strlen($alias) - 1)) === 0) { - // Same name as alias, do not use as - $items[$alias] = trim($full, '\\'); - } else { - $items[$alias] = trim($full, '\\') . ' as ' . $alias; - } - } - - sort($items); - - return $prefix . implode(",\n" . str_repeat(' ', strlen($prefix)), $items) . ";"; - } - - private const FORMAT_IMPORTS_MAP = [ - 'class' => 'use ', - 'func' => 'use function ', - 'const' => 'use const ', - ]; - - /** - * @param array $alias - * @param array $hints - * @param string $ns - * @return string - */ - private static function formatImports(array $alias, array $hints, string $ns = ""): string - { - if ($ns && $ns[0] !== '\\') { - $ns = '\\' . $ns; - } - - $use = []; - - foreach ($hints as $hint => $hintValue) { - if (($pos = strpos($hint, '\\')) !== false) { - // Relative - $hint = substr($hint, 0, $pos); - $hintValue = substr($hintValue, 0, $pos); - } - - foreach ($alias as $type => $values) { - if (!isset($values[$hint])) { - continue; - } - - if (strcasecmp($ns . '\\' . $hint, $values[$hint]) === 0) { - // Skip redundant import - continue; - } - - $use[$type][$hintValue] = $values[$hint]; - } - } - - if (!$use) { - return ''; - } - - $code = ''; - - foreach (self::FORMAT_IMPORTS_MAP as $key => $prefix) { - if (!isset($use[$key])) { - continue; - } - if ($add = self::formatUse($prefix, $use[$key])) { - if ($code) { - $code .= "\n"; - } - $code .= $add; - } - } - - return $code; - } /** - * @param ReflectionFunction $reflector + * @param \ReflectionFunction $reflector * @return ClosureInfo|null Returns null if not a real closure (a function, from callable) */ - public static function parse(ReflectionFunction $reflector): ?ClosureInfo + public static function parse($reflector): ?ClosureInfo { // Check if a valid closure - if (!$reflector->isClosure() || $reflector->isInternal() || !str_starts_with($reflector->getShortName(), '{closure')) { - return null; - } - - // Get file name - $file = $reflector->getFileName(); - - // Check if file name is present - if (!$file) { + if ( + !$reflector->isClosure() || + $reflector->isInternal() || + !str_starts_with($reflector->getShortName(), '{closure') + ) { return null; } - // Try already deserialized - // closure://... - if ($fromStream = ClosureStream::info($file)) { - return $fromStream; - } - - // Get file key - $fileKey = md5($file); - - // Get line bounds - $startLine = $reflector->getStartLine(); - $endLine = $reflector->getEndLine(); - - // compute top-level cache key - $cacheKey = "{$fileKey}/{$startLine}/{$endLine}"; - - // check cache - if (array_key_exists($cacheKey, self::$globalInfoCache)) { - return self::$globalInfoCache[$cacheKey]; - } - - // check file cache - if (!array_key_exists($fileKey, self::$globalFileCache)) { - self::$globalFileCache[$fileKey] = TokenizedFileInfo::getInfo($file); - } - - $fileInfo = self::$globalFileCache[$fileKey]; - - if ($fileInfo === null) { - return null; - } - - $ns = ''; - $aliases = null; - if ($fileInfo['namespaces']) { - if ($info = self::findNamespaceAliases($fileInfo['namespaces'], $startLine)) { - $ns = trim($info['ns'] ?? '', '\\'); - $aliases = $info['use'] ?? null; - } - } - - // create parser - $parser = new self($reflector, $fileInfo['tokens'], $ns, $aliases, $fileInfo['anonymous']); - - // cache result - return self::$globalInfoCache[$cacheKey] = $parser->info(); - } - - public static function init(): void - { - if (self::$BUILTIN_TYPES) { - // already initialized - return; - } - - self::$BUILTIN_TYPES = self::getBuiltInTypes(); + return self::resolve($reflector, ClosureInfo::name()); } /** - * @return string[] + * @param \ReflectionFunction $reflector + * @param string $ns + * @param array $fileInfo + * @param array|null $aliases + * @return static */ - private static function getBuiltInTypes(): array + protected static function create($reflector, string $ns, array $fileInfo, ?array $aliases): static { - // PHP 8 - - $types = [ - 'bool', 'int', 'float', 'string', 'array', - 'object', 'iterable', 'callable', 'void', 'mixed', - 'self', 'parent', 'static', - 'false', 'null', - ]; - - if (PHP_MINOR_VERSION >= 1) { - $types[] = 'never'; - } - - if (PHP_MINOR_VERSION >= 2) { - $types[] = 'true'; - } - - return $types; + return new self($reflector, $ns, $aliases, $fileInfo['tokens'], $fileInfo['anonymous']); } } \ No newline at end of file diff --git a/src/ClosureStream.php b/src/CodeStream.php similarity index 62% rename from src/ClosureStream.php rename to src/CodeStream.php index 6648778..ba1f436 100644 --- a/src/ClosureStream.php +++ b/src/CodeStream.php @@ -2,17 +2,23 @@ namespace Opis\Closure; -use Closure; - /** * @internal */ -final class ClosureStream +final class CodeStream { - const STREAM_PROTO = 'closure://'; + public const STREAM_PROTO = 'closure'; + + // this must be kept in sync with HANDLERS keys + private const REGEX = '/^' . self::STREAM_PROTO . ':\/\/([a-z]+)\/(.+)$/'; private static bool $isRegistered = false; + /** + * @var array Handler classes keyed by name + */ + private static array $handlers = []; + private ?string $content; private int $length = 0; @@ -26,7 +32,7 @@ final class ClosureStream public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { - $info = ClosureInfo::resolve(substr($path, strlen(self::STREAM_PROTO))); + $info = self::info($path); if (!$info) { return false; } @@ -104,25 +110,51 @@ public function stream_tell(): int return $this->pointer; } - public static function init(): void + /** + * @param array $handlers Class names that extend AbstractInfo + * @return void + */ + public static function init(array $handlers = []): void { if (!self::$isRegistered) { - // we remove :// - self::$isRegistered = stream_wrapper_register(substr(self::STREAM_PROTO, 0, -3), self::class); + self::$isRegistered = stream_wrapper_register(self::STREAM_PROTO, self::class); } + + // register handlers + foreach ($handlers as $class) { + self::$handlers[$class::name()] = $class; + } + } + + public static function include(AbstractInfo $info): mixed + { + return include_factory($info->url()); } - public static function factory(ClosureInfo $info): Closure + public static function info(string $url): ?AbstractInfo { - return include_factory(self::STREAM_PROTO . $info->key()); + if (!str_starts_with($url, self::STREAM_PROTO . '://')) { + return null; + } + + $m = self::classAndKey($url); + if (!$m) { + return null; + } + + return $m[0]::resolve($m[1]); } - public static function info(string $url): ?ClosureInfo + private static function classAndKey(string $url): ?array { - if (!str_starts_with($url, self::STREAM_PROTO)) { + $m = null; + if (!preg_match(self::REGEX, $url, $m)) { + return null; + } + if (!isset(self::$handlers[$m[1]])) { return null; } - return ClosureInfo::resolve(substr($url, strlen(self::STREAM_PROTO))); + return [self::$handlers[$m[1]], $m[2]]; } } @@ -130,6 +162,6 @@ public static function info(string $url): ?ClosureInfo * Use this function to get an unbound closure * @internal */ -function include_factory(string $url): Closure { +function include_factory(string $url): mixed { return include($url); } diff --git a/src/DeserializationHandler.php b/src/DeserializationHandler.php index fe08bf7..13b65a3 100644 --- a/src/DeserializationHandler.php +++ b/src/DeserializationHandler.php @@ -74,7 +74,7 @@ private function handleIterable(array|object &$iterable): void private function handleArray(array &$array): void { - $id = ClassInfo::refId($array); + $id = ReflectionClass::getRefId($array); if (!isset($this->visitedArrays[$id])) { $this->visitedArrays[$id] = true; $this->handleIterable($array); @@ -118,9 +118,10 @@ private function handleObject(object &$object): void // start unboxing $unboxed = match ($object->type) { - Box::TYPE_OBJECT => $this->unboxObject($object), + Box::TYPE_OBJECT => $this->unboxObject($object, false), Box::TYPE_CLOSURE => $this->unboxClosure($object), Box::TYPE_CALLABLE => $this->unboxCallable($object), + Box::TYPE_ANONYMOUS_CLASS => $this->unboxObject($object, true), }; // process references @@ -143,22 +144,28 @@ private function handleObject(object &$object): void $object = $unboxed; } - private function unboxObject(Box $box): object + private function unboxObject(Box $box, bool $isAnonymous): object { - $info = ClassInfo::get($box->data[0]); + // resolve class name + if ($isAnonymous) { + $class = AnonymousClassInfo::load($box->data[0])->loadClass(); + } else { + $class = $box->data[0]; + } + + // get reflection info + $info = ReflectionClass::get($class); - /** - * @var $data array|null - */ + // get a reference to data $data = &$box->data[1]; // we must always have an array $data ??= []; - $unserialize = $info->unserialize; - if (!$unserialize && !$info->hasMagicUnserialize) { - // if we don't have a custom unserializer, and we don't have __unserialize + $unserialize = $info->customDeserializer; + if (!$unserialize && !$info->hasMagicUnserialize()) { + // if we don't have a custom deserializer, and we don't have __unserialize // then use the generic object unserialize - $unserialize = [GenericObjectSerialization::class, "unserialize"]; + $unserialize = GenericObjectSerialization::UNSERIALIZE_CALLBACK; } if ($unserialize) { return $unserialize($data, function (?object $object, mixed &$value = null) use ($box, &$data): void { @@ -171,11 +178,11 @@ private function unboxObject(Box $box): object // handle $this->handle($value); } - }, $info->reflection); + }, $info); } // create a new object - $object = $info->reflection->newInstanceWithoutConstructor(); + $object = $info->newInstanceWithoutConstructor(); // we eagerly save cache $this->unboxed[$box] = $object; @@ -197,8 +204,15 @@ private function unboxCallable(Box $box): Closure { $callable = &$box->data; - if (is_array($callable) && is_object($callable[0])) { - $this->handleObject($callable[0]); + if (is_array($callable)) { + if (isset($callable[2])) { + // load anonymous class definition if any + AnonymousClassInfo::load($callable[2])->loadClass(); + unset($callable[2]); + } + if (is_object($callable[0])) { + $this->handleObject($callable[0]); + } } return Closure::fromCallable($callable); @@ -214,9 +228,6 @@ private function unboxClosure(Box $box): Closure "scope" => null, ]; - /** @var $info ClosureInfo */ - $info = $data["info"]; - if ($data["this"]) { $this->handleObject($data["this"]); } @@ -225,6 +236,15 @@ private function unboxClosure(Box $box): Closure $this->handleArray($data["vars"]); } + if (isset($data["anon"])) { + // load anonymous class definition if any + AnonymousClassInfo::load($data["anon"])->loadClass(); + } + + // in 4.1 data[info] was the object, we changed it to be an array + $info = ($data["info"] instanceof ClosureInfo) ? $data["info"] : ClosureInfo::load($data["info"]); + + // get the closure return $info->getClosure($data["vars"], $data["this"], $data["scope"]); } diff --git a/src/GenericObjectSerialization.php b/src/GenericObjectSerialization.php index d8803bf..b23becd 100644 --- a/src/GenericObjectSerialization.php +++ b/src/GenericObjectSerialization.php @@ -9,6 +9,9 @@ */ class GenericObjectSerialization { + public const SERIALIZE_CALLBACK = [self::class, "serialize"]; + public const UNSERIALIZE_CALLBACK = [self::class, "unserialize"]; + public static function serialize(object $object): array { $data = []; diff --git a/src/ReflectionClass.php b/src/ReflectionClass.php new file mode 100644 index 0000000..8f9682f --- /dev/null +++ b/src/ReflectionClass.php @@ -0,0 +1,120 @@ +_magicSerialize = $this->hasMethod("__serialize"); + $this->_magicUnserialize = $this->hasMethod("__unserialize"); + $this->_isAnonLike = parent::isAnonymous() || self::isAnonymousClassName($this->name); + // we always box anonymous + $this->useBoxing = $this->_isAnonLike || empty($this->getAttributes(Attribute\PreventBoxing::class)); + } + + public function hasMagicSerialize(): bool + { + return $this->_magicSerialize; + } + + public function hasMagicUnserialize(): bool + { + return $this->_magicUnserialize; + } + + /** + * @return bool True if this class is anonymous or contains ANONYMOUS_CLASS_PREFIX + */ + public function isAnonymousLike(): bool + { + return $this->_isAnonLike; + } + + public function info(): ?AnonymousClassInfo + { + if (!$this->_isAnonLike) { + // we don't provide info for non-anonymous classes + return null; + } + return $this->_info ??= AnonymousClassParser::parse($this); + } + + private static array $cache = []; + + public static function get(string|object $class): self + { + if (is_object($class)) { + $class = get_class($class); + } + return self::$cache[strtolower($class)] ??= new self($class); + } + + public static function clear(): void + { + self::$cache = []; + } + + private static ?bool $enumExists = null; + public static function objectIsEnum(object $value): bool + { + // enums were added in php 8.1 + self::$enumExists ??= interface_exists(\UnitEnum::class, false); + return self::$enumExists && ($value instanceof \UnitEnum); + } + + public static function getRefId(mixed &$reference): ?string + { + return \ReflectionReference::fromArrayElement([&$reference], 0)?->getId(); + } + + public static function isAnonymousClassName(string $class): bool + { + $pos = strrpos($class, '\\'); + if ($pos !== false) { + $class = substr($class, $pos + 1); + } + return str_starts_with($class, self::ANONYMOUS_CLASS_PREFIX); + } +} \ No newline at end of file diff --git a/src/ReflectionClosure.php b/src/ReflectionClosure.php index a22075e..b1d51e6 100644 --- a/src/ReflectionClosure.php +++ b/src/ReflectionClosure.php @@ -68,7 +68,7 @@ public function getCallableForm(): ?callable if ($scope = $this->getClosureScopeClass()) { // Static method - return $scope->getName() . '::' . $name; + return [$scope->getName(), $name]; } // Global function diff --git a/src/SerializationHandler.php b/src/SerializationHandler.php index 0bef9e4..5484810 100644 --- a/src/SerializationHandler.php +++ b/src/SerializationHandler.php @@ -15,7 +15,9 @@ class SerializationHandler private ?WeakMap $shouldBox; - private bool $hasAnonymousObjects; + private ?array $info; + + private bool $hasClosures; public function serialize(mixed $data): string { @@ -23,18 +25,19 @@ public function serialize(mixed $data): string $this->objectMap = new WeakMap(); $this->priority = new SplObjectStorage(); $this->shouldBox = new WeakMap(); - $this->hasAnonymousObjects = false; + $this->info = []; + $this->hasClosures = false; try { // get boxed structure $data = $this->handle($data); - if ($this->hasAnonymousObjects && $this->priority->count()) { + if ($this->hasClosures && $this->priority->count()) { // we only need priority when we have closures $data = new PriorityWrapper(iterator_to_array($this->priority), $data); } return serialize($data); } finally { - $this->arrayMap = $this->objectMap = $this->priority = $this->shouldBox = null; + $this->arrayMap = $this->objectMap = $this->priority = $this->shouldBox = $this->info = null; } } @@ -49,24 +52,24 @@ public function handle(mixed $data): mixed return $data; } - private function shouldBox(ClassInfo $info): bool + private function shouldBox(ReflectionClass $info): bool { if (isset($this->shouldBox[$info])) { // already marked return $this->shouldBox[$info]; } - if (!$info->box) { + if (!$info->useBoxing) { // explicit no box return $this->shouldBox[$info] = false; } - if ($info->serialize) { + if ($info->customSerializer) { // we have a custom serializer set return $this->shouldBox[$info] = true; } - if ($info->reflection->isInternal()) { + if ($info->isInternal()) { // internal classes are supported with custom serializers only return $this->shouldBox[$info] = false; } @@ -75,12 +78,32 @@ private function shouldBox(ClassInfo $info): bool return $this->shouldBox[$info] = true; } + private function getObjectVars(object $object, ReflectionClass $info): ?array + { + if ($serializer = $info->customSerializer ?? null) { + // we have a custom serializer + $vars = $serializer($object); + } elseif ($info->hasMagicSerialize()) { + // we have the magic __serialize + $vars = $object->__serialize(); + } else { + // we use a generic object serializer + $vars = GenericObjectSerialization::serialize($object); + } + + if (!is_array($vars) || !$vars) { + return null; + } + + return $this->handleArray($vars, true); + } + private function handleObject(object $data): object { if ( - ClassInfo::isEnum($data) || + ReflectionClass::objectIsEnum($data) || ($data instanceof Box) || - ($data instanceof ClosureInfo) + ($data instanceof AbstractInfo) ) { // we do need original serialization return $data; @@ -100,50 +123,51 @@ private function handleObject(object $data): object if ($data instanceof Closure) { // we found closures, mark it - $this->hasAnonymousObjects = true; + $this->hasClosures = true; // handle Closure return $this->handleClosure($data); } - $info = ClassInfo::get(get_class($data)); + $info = ReflectionClass::get(get_class($data)); if (!$this->shouldBox($info)) { // skip boxing return $this->objectMap[$data] = $data; } - $box = $this->objectMap[$data] = new Box(Box::TYPE_OBJECT, [$info->className(), null]); - - if ($serializer = $info->serialize ?? null) { - // we have a custom serializer - $vars = $serializer($data); - } elseif ($info->hasMagicSerialize) { - // we have the magic __serialize - $vars = $data->__serialize(); + if ($info->isAnonymousLike()) { + $anonInfo = AnonymousClassParser::parse($info); + $box = new Box(Box::TYPE_ANONYMOUS_CLASS, [null, null]); + $box->data[0] = &$this->getCachedInfo($anonInfo); + unset($anonInfo); } else { - // we use a generic object serializer - $vars = GenericObjectSerialization::serialize($data); + $box = new Box(Box::TYPE_OBJECT, [$info->name, null]); } - if (!empty($vars) && is_array($vars)) { - $box->data[1] = &$this->handleArray($vars); - } + // Set mapping (before vars!) + $this->objectMap[$data] = $box; + // Set vars + $box->data[1] = $this->getObjectVars($data, $info); + + // Add to priority $this->priority->attach($box); return $box; } - private function &handleArray(array &$data): array + private function &handleArray(array &$data, bool $skipRefId = false): array { - $id = ClassInfo::refId($data); - - if (array_key_exists($id, $this->arrayMap)) { - return $this->arrayMap[$id]; + if ($skipRefId) { + $box = []; + } else { + $id = ReflectionClass::getRefId($data); + if (array_key_exists($id, $this->arrayMap)) { + return $this->arrayMap[$id]; + } + $box = []; + $this->arrayMap[$id] = &$box; } - $box = []; - $this->arrayMap[$id] = &$box; - foreach ($data as $key => &$value) { if (is_object($value)) { $box[$key] = $this->handleObject($value); @@ -186,33 +210,75 @@ private function handleClosure(Closure $closure): Box if (($callable = $reflector->getCallableForm()) !== null) { $box->type = Box::TYPE_CALLABLE; - $box->data = $this->handle($callable); + if (is_object($callable)) { + $callable = $this->handleObject($callable); + } else if (is_array($callable)) { + if (is_object($callable[0])) { + $callable[0] = $this->handleObject($callable[0]); + } else if ($info = ReflectionClass::get($callable[0])->info()) { + // we have an anonymous + $callable[0] = $info->fullClassName(); + $callable[2] = &$this->getCachedInfo($info); + } + } + + $box->data = $callable; + return $box; } $closureInfo = $reflector->info(); $box->type = Box::TYPE_CLOSURE; - $box->data = [ - "info" => $closureInfo, - ]; + $box->data = []; + $box->data["info"] = &$this->getCachedInfo($closureInfo); - $object = $closureInfo->hasThis() ? $reflector->getClosureThis() : null; + $object = $closureInfo->hasThis() && !$closureInfo->isStatic() ? $reflector->getClosureThis() : null; $scope = $closureInfo->hasScope() ? $reflector->getClosureScopeClass() : null; - if ($object && !$closureInfo->isStatic()) { + if ($object) { $box->data["this"] = $this->handleObject($object); + $scope ??= $reflector->getClosureScopeClass(); } - // Do not add internal or anonymous scope - if ($scope && !$scope->isInternal() && !$scope->isAnonymous()) { - $box->data["scope"] = $scope->getName(); + if ($scope && !$scope->isInternal()) { + $scopeClass = $scope->name; + if ($scope->isAnonymous() || ReflectionClass::isAnonymousClassName($scopeClass)) { + if (!$object && $closureInfo->hasScope()) { + // this is a tricky case + // we don't have $this, but we must make sure the anonymous class is available for static::/self:: + // this works on a local machine because the class name is something like: + // class@anonymous/path/to/file.php:31$0 + // but on another machine we have to make it available + $anonInfo = ReflectionClass::get($scopeClass)->info(); + $scopeClass = $anonInfo->fullClassName(); + $box->data["anon"] = &$this->getCachedInfo($anonInfo); + unset($anonInfo); + } else { + // we don't need scope when we have $this in anonymous + $scopeClass = null; + } + } + if ($scopeClass) { + $box->data["scope"] = $scopeClass; + } + unset($scopeClass); } if ($use = $reflector->getUseVariables()) { - $box->data["vars"] = &$this->handleArray($use); + $box->data["vars"] = &$this->handleArray($use, true); } return $box; } + + private function &getCachedInfo(AbstractInfo $info): array + { + // this way we reduce the serialized string size + // bonus, at deserialization we can load an existing + // object by looking at "key" prop + $key = $info::name() . '/' . $info->key(); + $this->info[$key] ??= $info->__serialize(); + return $this->info[$key]; + } } \ No newline at end of file diff --git a/src/Serializer.php b/src/Serializer.php index 8efad31..ebb7677 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -15,8 +15,6 @@ final class Serializer { private static bool $init = false; - public static string $uniqKey; - private static ?SecurityProviderInterface $securityProvider = null; public static bool $v3Compatible = false; @@ -46,20 +44,17 @@ public static function init( } } - // Init closure parser - ClosureParser::init(); + // Init parser + AbstractParser::init(); - // Init closure stream protocol - ClosureStream::init(); + // Init code stream protocol + CodeStream::init([ClosureInfo::class, AnonymousClassInfo::class]); // Set security provider if ($security) { self::setSecurityProvider($security); } - // Set uniq key - self::$uniqKey = '@(opis/closure):key:' . chr(0) . uniqid() . chr(8); - // add spl serializations self::register( \ArrayObject::class, @@ -239,7 +234,7 @@ public static function decode(string $data, ?SecurityProviderInterface $security public static function preventBoxing(string ...$class): void { foreach ($class as $cls) { - ClassInfo::get($cls)->box = false; + ReflectionClass::get($cls)->useBoxing = false; } } @@ -252,9 +247,9 @@ public static function preventBoxing(string ...$class): void */ public static function register(string $class, ?callable $serialize, ?callable $unserialize): void { - $data = ClassInfo::get($class); - $data->serialize = $serialize; - $data->unserialize = $unserialize; + $data = ReflectionClass::get($class); + $data->customSerializer = $serialize; + $data->customDeserializer = $unserialize; } /** diff --git a/src/TokenizedFileInfo.php b/src/TokenizedFileInfo.php index f75fa39..60c1678 100644 --- a/src/TokenizedFileInfo.php +++ b/src/TokenizedFileInfo.php @@ -406,7 +406,11 @@ private function handleAnonymousClass(): void } // Now we can check body - $start = $this->index + 1; + $start = $this->index; + + if ($this->tokens[$start] !== "{") { + $start++; + } $this->insideAnonymousClass++; foreach ($this->balanceCurly() as $token) { diff --git a/tests/PHP80/AnonymousClassTest.php b/tests/PHP80/AnonymousClassTest.php new file mode 100644 index 0000000..d175ffa --- /dev/null +++ b/tests/PHP80/AnonymousClassTest.php @@ -0,0 +1,121 @@ +process($v); + + $this->assertEquals(123, $u->value); + } + + public function testComplex() + { + $factory = fn(?Objects\Entity $parent) => new class($parent) extends Objects\Entity { + public function __construct(?Objects\Entity $parent) + { + $this->parent = $parent; + } + }; + + $a = $factory(null); + $b = $factory($a); + + // process twice + $u = $this->process([$a, $b]); + $u = $this->process($u); + + $this->assertInstanceOf(Objects\Entity::class, $u[0]); + $this->assertInstanceOf(Objects\Entity::class, $u[1]); + + $this->assertNull($u[0]->parent); + $this->assertEquals($u[0], $u[1]->parent); + } + + public function testBoundClosure() + { + $v = new class extends Clone1 {}; + + $closure = $this->process($v->create()); + + $this->assertEquals(1, $closure()); + } + + public function testScopeOnly() + { + /** + * this is an interesting case + * we don't have $this bound, but we need the static methods + * from anonymous class + */ + $v = new class { + public static function create() + { + return static function () { + return self::test(); + }; + } + + public static function test() { + return "ok"; + } + }; + + $s = $this->s($v::create()); + $this->clearCache(); // clear cache so we don't have info in memory + $closure = $this->u($s); + + $this->assertEquals("ok", $closure()); + } + + public function testCallable() + { + $v = new class { + public static function test() { + return "ok"; + } + }; + + $closure = $this->process(\Closure::fromCallable([get_class($v), "test"])); + + $this->assertEquals("ok", $closure()); + } + + public function testTrait() + { + $v = new class { + use Objects\StaticTrait1; + }; + + $closure = $this->process($v::create()); + $this->assertEquals("ok-trait", $closure()); + } + + public function testTraitAlias() + { + $v = new class { + use Objects\StaticTrait1 { + test as test_trait; + } + + public static function test() + { + return "ok-class"; + } + }; + + $closure = $this->process($v::create()); + $this->assertEquals("ok-class", $closure()); + } +} \ No newline at end of file diff --git a/tests/PHP80/Objects/StaticTrait1.php b/tests/PHP80/Objects/StaticTrait1.php new file mode 100644 index 0000000..b915887 --- /dev/null +++ b/tests/PHP80/Objects/StaticTrait1.php @@ -0,0 +1,16 @@ +u("sum.security", "other-secret"); } + public function testAnonymousClassComplex() + { + $u = $this->u("anon.complex"); + $this->assertInstanceOf(Objects\Entity::class, $u[0]); + $this->assertInstanceOf(Objects\Entity::class, $u[1]); + + $this->assertNull($u[0]->parent); + $this->assertEquals($u[0], $u[1]->parent); + } + private function u(string $name, SecurityProviderInterface|string|null $security = null): mixed { $data = file_get_contents(__DIR__ . "/v4/{$name}.bin"); diff --git a/tests/PHP80/v4/anon.complex.bin b/tests/PHP80/v4/anon.complex.bin new file mode 100644 index 0000000..e158c4f --- /dev/null +++ b/tests/PHP80/v4/anon.complex.bin @@ -0,0 +1,6 @@ +a:2:{i:0;O:16:"Opis\Closure\Box":2:{i:0;i:4;i:1;a:2:{i:0;a:4:{s:3:"key";s:32:"3f8961a5672a610d4e034a9932cf59d8";s:6:"header";s:34:"namespace Opis\Closure\Test\PHP80;";s:4:"body";s:205:"class opisanonymous@classname extends Objects\Entity { + public function __construct(?Objects\Entity $parent) + { + $this->parent = $parent; + } + }";s:2:"ns";s:23:"Opis\Closure\Test\PHP80";}i:1;a:2:{i:0;N;i:1;a:0:{}}}}i:1;O:16:"Opis\Closure\Box":2:{i:0;i:4;i:1;a:2:{i:0;R:5;i:1;a:2:{i:0;r:2;i:1;a:0:{}}}}} \ No newline at end of file diff --git a/tests/SerializeTestCase.php b/tests/SerializeTestCase.php index 9bec64b..77192b8 100644 --- a/tests/SerializeTestCase.php +++ b/tests/SerializeTestCase.php @@ -2,7 +2,8 @@ namespace Opis\Closure\Test; -use Opis\Closure\ClosureInfo; +use Opis\Closure\AbstractInfo; +use Opis\Closure\AbstractParser; use Opis\Closure\Serializer; use PHPUnit\Framework\TestCase; @@ -13,11 +14,27 @@ protected function process(mixed $value): mixed return Serializer::unserialize(Serializer::serialize($value)); } + protected function s(mixed $value): string + { + return Serializer::serialize($value); + } + + protected function u(string $value): mixed + { + return Serializer::unserialize($value); + } + protected function tearDown(): void { // clear cache if any - ClosureInfo::clear(); + $this->clearCache(); // do not keep security provider Serializer::setSecurityProvider(null); } + + protected function clearCache(): void + { + AbstractInfo::clear(); + AbstractParser::clear(); + } } \ No newline at end of file