src/AnonymousClassStasis.php source

1 <?php
2
3 declare(strict_types=1);
4
5 namespace Serializor;
6
7 use PhpToken;
8 use ReflectionClass;
9 use ReflectionObject;
10
11 /**
12 * Stasis for anonymous class instances.
13 * Extracts and stores the class definition source code.
14 */
15 final class AnonymousClassStasis extends Stasis
16 {
17 private const STARTING = 0;
18 private const CONSTRUCTOR_ARGS = 1;
19 private const BEFORE_BODY = 2;
20 private const BODY = 4;
21 private const CLASS_MEMBER_NAME = 5;
22 private const CLASS_MEMBER_BODY = 6;
23 private const CLASS_CONSTRUCTOR_ARGS = 7;
24 private const DONE = 255;
25
26 private string $hash;
27 private string $code;
28 private ?string $extends = null;
29 private array $implements = [];
30 private array $props = [];
31
32 private static array $tokenCache = [];
33 private static array $functionCache = [];
34 private static array $classMakerCache = [];
35
36 private function __construct() {}
37
38 public function __serialize(): array
39 {
40 $data = ['h' => $this->hash, 'c' => $this->code, 'p' => $this->props];
41 if ($this->extends !== null) {
42 $data['e'] = $this->extends;
43 }
44 if (!empty($this->implements)) {
45 $data['i'] = $this->implements;
46 }
47 return $data;
48 }
49
50 public function __unserialize(array $data): void
51 {
52 $this->hash = $data['h'];
53 $this->code = $data['c'];
54 $this->props = $data['p'];
55 $this->extends = $data['e'] ?? null;
56 $this->implements = $data['i'] ?? [];
57 }
58
59 public function getClassName(): string
60 {
61 return 'class@anonymous';
62 }
63
64 public static function fromObject(object $value, ReflectionClass $rc): AnonymousClassStasis
65 {
66 $ro = new ReflectionObject($value);
67 $frozen = new AnonymousClassStasis();
68
69 $parentRo = $ro->getParentClass();
70 $frozen->extends = $parentRo ? $parentRo->getName() : null;
71 $frozen->implements = $ro->getInterfaceNames();
72
73 $frozen->hash = self::getClassHash($ro);
74 $frozen->code = self::getCode($ro, ['__construct'], $frozen->extends, $frozen->implements);
75 $frozen->props = Stasis::getObjectProperties($value);
76
77 return $frozen;
78 }
79
80 /**
81 * Get the properties for transformation.
82 */
83 public function &getProps(): array
84 {
85 return $this->props;
86 }
87
88 public function &getInstance(): mixed
89 {
90 if ($this->hasInstance()) {
91 return $this->getCachedInstance();
92 }
93
94 if (!isset(self::$classMakerCache[$this->hash])) {
95 $code = 'return static function() {
96 return new ' . $this->code . ';
97 };';
98 self::$classMakerCache[$this->hash] = eval($code);
99 }
100
101 $instance = self::$classMakerCache[$this->hash]();
102
103 // Resolve any remaining Stasis objects in props before setting typed properties.
104 // This handles circular references where the reference chain update didn't complete
105 // before getInstance() was called.
106 foreach ($this->props as $k => $v) {
107 if ($v instanceof Stasis) {
108 $this->props[$k] = $v->getInstance();
109 }
110 }
111
112 Stasis::setObjectProperties($instance, $this->props);
113
114 $this->setInstance($instance);
115 return $instance;
116 }
117
118 private static function getCode(ReflectionObject $ro, array $discardMembers = ['__construct'], ?string $expectedExtends = null, array $expectedImplements = []): string
119 {
120 $hash = self::getClassHash($ro);
121 if (isset(self::$functionCache[$hash])) {
122 return self::$functionCache[$hash]['code'];
123 }
124 $sourceFile = $ro->getFileName();
125 if (isset(self::$tokenCache[$sourceFile])) {
126 $tokens = self::$tokenCache[$sourceFile];
127 } else {
128 $tokens = self::$tokenCache[$sourceFile] = PhpToken::tokenize(file_get_contents($sourceFile));
129 }
130
131 // Count anonymous classes on the same line for ambiguity detection
132 $classesOnLine = 0;
133 foreach ($tokens as $t) {
134 if ($t->line === $ro->getStartLine() && $t->id === T_CLASS) {
135 $classesOnLine++;
136 }
137 }
138
139 $constructorMembers = [];
140 $currentConstructorMemberToken = null;
141
142 $capture = false;
143 $capturedTokens = [];
144 $stackDepth = 0;
145 $state = self::STARTING;
146 $stateChangeToken = null;
147 $memberNameToken = null;
148 $stack = [];
149
150 // For disambiguation: track extends/implements found in source
151 $foundExtends = null;
152 $foundImplements = [];
153 $collectingExtends = false;
154 $collectingImplements = false;
155 $matchCount = 0; // Count classes that match extends/implements
156
157 foreach ($tokens as $token) {
158 if (!$capture) {
159 if ($token->line === $ro->getStartLine() && $token->id === T_CLASS) {
160 $capture = true;
161 // Reset disambiguation tracking
162 $foundExtends = null;
163 $foundImplements = [];
164 $collectingExtends = false;
165 $collectingImplements = false;
166 } else {
167 continue;
168 }
169 }
170 if (!$token->isIgnorable()) {
171 if ($stackDepth === 0 && $state !== self::STARTING && $state !== self::BEFORE_BODY && \str_contains(",)}];", $token->text)) {
172 break;
173 }
174 }
175
176 // Track extends/implements in STARTING and BEFORE_BODY states
177 if ($state === self::STARTING || $state === self::CONSTRUCTOR_ARGS || $state === self::BEFORE_BODY) {
178 $isNameToken = $token->id === T_STRING
179 || $token->id === T_NAME_QUALIFIED
180 || $token->id === T_NAME_FULLY_QUALIFIED;
181
182 if ($token->id === T_EXTENDS) {
183 $collectingExtends = true;
184 $collectingImplements = false;
185 } elseif ($token->id === T_IMPLEMENTS) {
186 $collectingImplements = true;
187 $collectingExtends = false;
188 } elseif ($collectingExtends && $isNameToken) {
189 $foundExtends = ltrim($token->text, '\\');
190 $collectingExtends = false;
191 } elseif ($collectingImplements && $isNameToken) {
192 $foundImplements[] = ltrim($token->text, '\\');
193 }
194 }
195
196 if ($state === self::STARTING && $token->text === '(') {
197 $state = self::CONSTRUCTOR_ARGS;
198 $stateChangeToken = $token;
199 } elseif ($state === self::CONSTRUCTOR_ARGS && $token->text === ')') {
200 $state = self::BEFORE_BODY;
201 while ($capturedTokens[count($capturedTokens) - 1] !== $stateChangeToken) {
202 array_pop($capturedTokens);
203 }
204 $stateChangeToken = $token;
205 } elseif ($state === self::STARTING && $token->text === '{') {
206 $state = self::BODY;
207 $stateChangeToken = $token;
208 } elseif ($state === self::BEFORE_BODY && $token->text === '{') {
209 $state = self::BODY;
210 $stateChangeToken = $token;
211 } elseif ($state === self::BODY && $stackDepth === 1 && $token->text === '}') {
212 $state = self::DONE;
213 $stateChangeToken = $token;
214 } elseif ($state === self::BODY && $stackDepth === 1) {
215 if (!$token->isIgnorable()) {
216 $state = self::CLASS_MEMBER_NAME;
217 $stateChangeToken = $token;
218 }
219 } elseif ($state === self::CLASS_MEMBER_NAME && $stackDepth === 1) {
220 if (!$token->isIgnorable()) {
221 if ($token->text === ';') {
222 $state = self::BODY;
223 } elseif (in_array($token->text, ['=', '{'])) {
224 $state = self::CLASS_MEMBER_BODY;
225 } elseif ($token->text === '(' && $memberNameToken?->text === '__construct') {
226 $state = self::CLASS_CONSTRUCTOR_ARGS;
227 } else {
228 $memberNameToken = $token;
229 }
230 }
231 } elseif ($state === self::CLASS_CONSTRUCTOR_ARGS) {
232 if (in_array($token->text, [')', ',']) && $stackDepth === 2) {
233 if ($currentConstructorMemberToken !== null) {
234 $tmpTokens = [];
235 do {
236 $topToken = array_pop($capturedTokens);
237 $tmpTokens[] = $topToken;
238 if ($topToken->text === '=') {
239 $tmpTokens = [];
240 $testWhitespace = array_pop($capturedTokens);
241 if (!$testWhitespace->isIgnorable()) {
242 $capturedTokens[] = $testWhitespace;
243 }
244 }
245 } while ($topToken !== $currentConstructorMemberToken);
246 $currentConstructorMemberToken = null;
247 $constructorMembers[] = array_reverse($tmpTokens);
248 }
249 if ($token->text === ')') {
250 $state = self::CLASS_MEMBER_BODY;
251 }
252 } elseif ($currentConstructorMemberToken === null && !$token->isIgnorable()) {
253 if (in_array($token->text, ['public', 'protected', 'private'])) {
254 $currentConstructorMemberToken = $token;
255 }
256 }
257 } elseif ($state === self::CLASS_MEMBER_BODY) {
258 if ($stackDepth === 2 && $token->text === '}') {
259 $state = self::BODY;
260 } elseif ($stackDepth === 1 && $token->text === ';') {
261 $state = self::BODY;
262 }
263 }
264
265 $capturedTokens[] = $token;
266
267 if ($state === self::BODY && $memberNameToken !== null) {
268 if (in_array($memberNameToken->text, $discardMembers)) {
269 while ($capturedTokens !== [] && array_pop($capturedTokens) !== $stateChangeToken) {
270 }
271 }
272 if ($memberNameToken->text === '__construct') {
273 foreach ($constructorMembers as $member) {
274 foreach ($member as $memberToken) {
275 $capturedTokens[] = $memberToken;
276 }
277 $capturedTokens[] = new PhpToken(59, ';');
278 }
279 }
280 $memberNameToken = null;
281 }
282
283
284 if ($token->text === '{') {
285 $stack[$stackDepth++] = '}';
286 } elseif ($token->text === '(') {
287 $stack[$stackDepth++] = ')';
288 } elseif ($token->text === '[') {
289 $stack[$stackDepth++] = ']';
290 } elseif ($stackDepth > 0 && $stack[$stackDepth - 1] === $token->text) {
291 --$stackDepth;
292 if ($stackDepth === 0 && $token->text === '}') {
293 // Check if this class matches expected extends/implements
294 $extendsMatch = self::matchesExtends($foundExtends, $expectedExtends);
295 $implementsMatch = self::matchesImplements($foundImplements, $expectedImplements);
296
297 if ($extendsMatch && $implementsMatch) {
298 $matchCount++;
299 if ($classesOnLine > 1) {
300 // Multiple classes on line - need to check for ambiguity
301 if (!isset($savedTokens)) {
302 // First match - save but keep looking
303 $savedTokens = $capturedTokens;
304 $savedConstructorMembers = $constructorMembers;
305 $capture = false;
306 $capturedTokens = [];
307 $state = self::STARTING;
308 $constructorMembers = [];
309 $currentConstructorMemberToken = null;
310 } else {
311 // Found a SECOND match - ambiguous!
312 throw new SerializerError(
313 'Cannot serialize anonymous class: multiple anonymous classes found on the same line '
314 . 'and cannot be disambiguated by extends/implements'
315 );
316 }
317 } else {
318 // Single class on line
319 break;
320 }
321 } elseif ($token->line === $ro->getStartLine()) {
322 // Wrong class on same line, keep looking
323 $capture = false;
324 $capturedTokens = [];
325 $state = self::STARTING;
326 $constructorMembers = [];
327 $currentConstructorMemberToken = null;
328 } else {
329 break; // Moved past the line
330 }
331 }
332 }
333 if ($state === self::DONE) {
334 break;
335 }
336 }
337 // After loop: check if we saved a unique match
338 if (empty($capturedTokens) && isset($savedTokens)) {
339 // We had exactly one match and checked all classes
340 $capturedTokens = $savedTokens;
341 $constructorMembers = $savedConstructorMembers;
342 }
343
344 if (empty($capturedTokens)) {
345 throw new SerializerError(
346 'Cannot serialize anonymous class: multiple anonymous classes found on the same line '
347 . 'and cannot be disambiguated by extends/implements'
348 );
349 }
350
351 $codes = [];
352 foreach ($capturedTokens as $token) {
353 $codes[] = $token->text;
354 }
355
356 self::$functionCache[$hash] = [
357 'code' => \implode('', $codes)
358 ];
359
360 return self::$functionCache[$hash]['code'];
361 }
362
363 /**
364 * Check if found extends matches expected (handles short vs fully qualified names).
365 */
366 private static function matchesExtends(?string $found, ?string $expected): bool
367 {
368 if ($found === null && $expected === null) {
369 return true;
370 }
371 if ($found === null || $expected === null) {
372 return false;
373 }
374 // Compare short names (last part after \)
375 $foundShort = substr($found, (int) strrpos($found, '\\') + 1);
376 $expectedShort = substr($expected, (int) strrpos($expected, '\\') + 1);
377 return $foundShort === $expectedShort;
378 }
379
380 /**
381 * Check if found implements matches expected interfaces.
382 */
383 private static function matchesImplements(array $found, array $expected): bool
384 {
385 if (empty($found) && empty($expected)) {
386 return true;
387 }
388 if (count($found) !== count($expected)) {
389 return false;
390 }
391 // Compare short names
392 $foundShort = array_map(fn($n) => substr($n, (int) strrpos($n, '\\') + 1), $found);
393 $expectedShort = array_map(fn($n) => substr($n, (int) strrpos($n, '\\') + 1), $expected);
394 sort($foundShort);
395 sort($expectedShort);
396 return $foundShort === $expectedShort;
397 }
398
399 private static function getClassHash(ReflectionObject $ro): string
400 {
401 $pco = $ro->getParentClass();
402 $interfaces = $ro->getInterfaceNames();
403 sort($interfaces);
404 $hash = ($ro->getDocComment() ?: '')
405 . ($ro->getFileName() ?: '')
406 . ($ro->getStartLine() ?: '')
407 . ($ro->getEndLine() ?: '')
408 . ($ro->getName())
409 . ($ro->getShortName())
410 . ($pco ? $pco->getName() : '')
411 . implode(',', $interfaces);
412 return md5($hash);
413 }
414 }
415