Skip to content

Commit fea3574

Browse files
[ErrorHandler] make DebugClassLoader able to add return type declarations
1 parent 123fc62 commit fea3574

File tree

1 file changed

+187
-34
lines changed

1 file changed

+187
-34
lines changed

src/Symfony/Component/ErrorHandler/DebugClassLoader.php

Lines changed: 187 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class DebugClassLoader
4242
'bool' => 'bool',
4343
'callable' => 'callable',
4444
'float' => 'float',
45-
'int' => 'integer',
45+
'int' => 'int',
4646
'iterable' => 'iterable',
4747
'object' => 'object',
4848
'string' => 'string',
@@ -64,10 +64,79 @@ class DebugClassLoader
6464
'parent' => true,
6565
];
6666

67+
private const MAGIC_METHODS = [
68+
'__set' => 'void',
69+
'__isset' => 'bool',
70+
'__unset' => 'void',
71+
'__sleep' => 'array',
72+
'__wakeup' => 'void',
73+
'__toString' => 'string',
74+
'__clone' => 'void',
75+
'__debugInfo' => 'array',
76+
'__serialize' => 'array',
77+
'__unserialize' => 'void',
78+
];
79+
80+
private const INTERNAL_TYPES = [
81+
'ArrayAccess' => [
82+
'offsetExists' => 'bool',
83+
'offsetSet' => 'void',
84+
'offsetUnset' => 'void',
85+
],
86+
'Countable' => [
87+
'count' => 'int',
88+
],
89+
'Iterator' => [
90+
'next' => 'void',
91+
'valid' => 'bool',
92+
'rewind' => 'void',
93+
],
94+
'IteratorAggregate' => [
95+
'getIterator' => '\Traversable',
96+
],
97+
'OuterIterator' => [
98+
'getInnerIterator' => '\Iterator',
99+
],
100+
'RecursiveIterator' => [
101+
'hasChildren' => 'bool',
102+
],
103+
'SeekableIterator' => [
104+
'seek' => 'void',
105+
],
106+
'Serializable' => [
107+
'serialize' => 'string',
108+
'unserialize' => 'void',
109+
],
110+
'SessionHandlerInterface' => [
111+
'open' => 'bool',
112+
'close' => 'bool',
113+
'read' => 'string',
114+
'write' => 'bool',
115+
'destroy' => 'bool',
116+
'gc' => 'bool',
117+
],
118+
'SessionIdInterface' => [
119+
'create_sid' => 'string',
120+
],
121+
'SessionUpdateTimestampHandlerInterface' => [
122+
'validateId' => 'bool',
123+
'updateTimestamp' => 'bool',
124+
],
125+
'Throwable' => [
126+
'getMessage' => 'string',
127+
'getCode' => 'int',
128+
'getFile' => 'string',
129+
'getLine' => 'int',
130+
'getTrace' => 'array',
131+
'getPrevious' => '?\Throwable',
132+
'getTraceAsString' => 'string',
133+
],
134+
];
135+
67136
private $classLoader;
68137
private $isFinder;
69138
private $loaded = [];
70-
private $compatPatch;
139+
private $patchTypes;
71140
private static $caseCheck;
72141
private static $checkedClasses = [];
73142
private static $final = [];
@@ -80,12 +149,13 @@ class DebugClassLoader
80149
private static $method = [];
81150
private static $returnTypes = [];
82151
private static $methodTraits = [];
152+
private static $fileOffsets = [];
83153

84154
public function __construct(callable $classLoader)
85155
{
86156
$this->classLoader = $classLoader;
87157
$this->isFinder = \is_array($classLoader) && method_exists($classLoader[0], 'findFile');
88-
$this->compatPatch = getenv('SYMFONY_PATCH_TYPE_DECLARATIONS_COMPAT') ?: null;
158+
parse_str(getenv('SYMFONY_PATCH_TYPE_DECLARATIONS') ?: '', $this->patchTypes);
89159

90160
if (!isset(self::$caseCheck)) {
91161
$file = file_exists(__FILE__) ? __FILE__ : rtrim(realpath('.'), \DIRECTORY_SEPARATOR);
@@ -160,13 +230,22 @@ public static function disable(): void
160230
spl_autoload_unregister($function);
161231
}
162232

233+
$loader = null;
234+
163235
foreach ($functions as $function) {
164236
if (\is_array($function) && $function[0] instanceof self) {
237+
$loader = $function[0];
165238
$function = $function[0]->getClassLoader();
166239
}
167240

168241
spl_autoload_register($function);
169242
}
243+
244+
if (null !== $loader) {
245+
foreach (array_merge(get_declared_interfaces(), get_declared_traits(), get_declared_classes()) as $class) {
246+
$loader->checkClass($class);
247+
}
248+
}
170249
}
171250

172251
public function findFile(string $class): ?string
@@ -256,6 +335,9 @@ private function checkClass(string $class, string $file = null): void
256335

257336
public function checkAnnotations(\ReflectionClass $refl, string $class): array
258337
{
338+
if ('Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV7' === $class) {
339+
return [];
340+
}
259341
$deprecations = [];
260342

261343
// Don't trigger deprecations for classes in the same vendor
@@ -348,7 +430,7 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
348430
if (trait_exists($class)) {
349431
$file = $refl->getFileName();
350432

351-
foreach ($refl->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) as $method) {
433+
foreach ($refl->getMethods() as $method) {
352434
if ($method->getFileName() === $file) {
353435
self::$methodTraits[$file][$method->getStartLine()] = $class;
354436
}
@@ -368,9 +450,17 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
368450
self::${$property}[$class] = self::${$property}[$class] ? self::${$property}[$use] + self::${$property}[$class] : self::${$property}[$use];
369451
}
370452
}
453+
454+
if (null !== (self::INTERNAL_TYPES[$use] ?? null)) {
455+
foreach (self::INTERNAL_TYPES[$use] as $method => $returnType) {
456+
if ('void' !== $returnType) {
457+
self::$returnTypes[$class] += [$method => [$returnType, $returnType, $class, '']];
458+
}
459+
}
460+
}
371461
}
372462

373-
foreach ($refl->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) as $method) {
463+
foreach ($refl->getMethods() as $method) {
374464
if ($method->class !== $class) {
375465
continue;
376466
}
@@ -413,34 +503,71 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
413503
}
414504
}
415505

416-
if (isset(self::$returnTypes[$class][$method->name]) && !$method->hasReturnType() && !($doc && preg_match('/\n\s+\* @return +(\S+)/', $doc))) {
417-
list($normalizedType, $returnType, $declaringClass, $declaringFile) = self::$returnTypes[$class][$method->name];
506+
$forcePatchTypes = $this->patchTypes['force'] ?? null;
507+
508+
if ($canAddReturnType = null !== $forcePatchTypes && false === strpos($method->getFileName(), \DIRECTORY_SEPARATOR.'vendor'.\DIRECTORY_SEPARATOR)) {
509+
if ('void' !== (self::MAGIC_METHODS[$method->name] ?? 'void')) {
510+
$this->patchTypes['force'] = $forcePatchTypes ?: 'docblock';
511+
}
512+
513+
$canAddReturnType = ($this->patchTypes['force'] ?? false)
514+
|| false !== strpos($refl->getFileName(), \DIRECTORY_SEPARATOR.'Tests'.\DIRECTORY_SEPARATOR)
515+
|| $refl->isFinal()
516+
|| $method->isFinal()
517+
|| $method->isPrivate()
518+
|| ('' === (self::$internal[$class] ?? null) && !$refl->isAbstract())
519+
|| '' === (self::$final[$class] ?? null)
520+
|| preg_match('/@(final|internal)$/m', $doc)
521+
;
522+
}
418523

419-
if (null !== $this->compatPatch && 0 === strpos($class, $this->compatPatch)) {
420-
self::fixReturnStatements($method, $normalizedType);
524+
if (null !== ($returnType = self::$returnTypes[$class][$method->name] ?? self::MAGIC_METHODS[$method->name] ?? null) && !$method->hasReturnType() && !($doc && preg_match('/\n\s+\* @return +(\S+)/', $doc))) {
525+
if ('void' === $returnType) {
526+
$canAddReturnType = false;
527+
}
528+
529+
list($normalizedType, $returnType, $declaringClass, $declaringFile) = \is_string($returnType) ? [$returnType, $returnType, '', ''] : $returnType;
530+
531+
if ($canAddReturnType && 'docblock' !== ($this->patchTypes['force'] ?? false)) {
532+
$this->patchMethod($method, $returnType, $declaringFile, $normalizedType);
421533
}
422534

423535
if (strncmp($ns, $declaringClass, $len)) {
424-
if (null !== $this->compatPatch && 0 === strpos($class, $this->compatPatch)) {
425-
self::patchMethod($method, $returnType, $declaringFile);
536+
if ($canAddReturnType && 'docblock' === ($this->patchTypes['force'] ?? false) && false === strpos($method->getFileName(), \DIRECTORY_SEPARATOR.'vendor'.\DIRECTORY_SEPARATOR)) {
537+
$this->patchMethod($method, $returnType, $declaringFile, $normalizedType);
538+
} elseif ('' !== $declaringClass) {
539+
$deprecations[] = sprintf('Method "%s::%s()" will return "%s" as of its next major version. Doing the same in child class "%s" will be required when upgrading.', $declaringClass, $method->name, $normalizedType, $class);
426540
}
427-
428-
$deprecations[] = sprintf('Method "%s::%s()" will return "%s" as of its next major version. Doing the same in child class "%s" will be required when upgrading.', $declaringClass, $method->name, $normalizedType, $class);
429541
}
430542
}
431543

432544
if (!$doc) {
545+
$this->patchTypes['force'] = $forcePatchTypes;
546+
433547
continue;
434548
}
435549

436-
if (!$method->hasReturnType() && false !== strpos($doc, '@return') && preg_match('/\n\s+\* @return +(\S+)/', $doc, $matches)) {
550+
$matches = [];
551+
552+
if (!$method->hasReturnType() && ((false !== strpos($doc, '@return') && preg_match('/\n\s+\* @return +(\S+)/', $doc, $matches)) || 'void' !== (self::MAGIC_METHODS[$method->name] ?? 'void'))) {
553+
$matches = $matches ?: [1 => self::MAGIC_METHODS[$method->name]];
437554
$this->setReturnType($matches[1], $method, $parent);
438555

439-
if (null !== $this->compatPatch && 0 === strpos($class, $this->compatPatch)) {
440-
self::fixReturnStatements($method, self::$returnTypes[$class][$method->name][0] ?? '?');
556+
if (isset(self::$returnTypes[$class][$method->name][0]) && $canAddReturnType) {
557+
$this->fixReturnStatements($method, self::$returnTypes[$class][$method->name][0]);
558+
}
559+
560+
if ($method->isPrivate()) {
561+
unset(self::$returnTypes[$class][$method->name]);
441562
}
442563
}
443564

565+
$this->patchTypes['force'] = $forcePatchTypes;
566+
567+
if ($method->isPrivate()) {
568+
continue;
569+
}
570+
444571
$finalOrInternal = false;
445572

446573
foreach (['final', 'internal'] as $annotation) {
@@ -609,9 +736,13 @@ private function setReturnType(string $types, \ReflectionMethod $method, ?string
609736
$typesMap[$this->normalizeType($t, $method->class, $parent)] = $t;
610737
}
611738

612-
if (isset($typesMap['array']) && (isset($typesMap['Traversable']) || isset($typesMap['\Traversable']))) {
613-
$typesMap['iterable'] = 'array' !== $typesMap['array'] ? $typesMap['array'] : 'iterable';
614-
unset($typesMap['array'], $typesMap['Traversable'], $typesMap['\Traversable']);
739+
if (isset($typesMap['array'])) {
740+
if (isset($typesMap['Traversable']) || isset($typesMap['\Traversable'])) {
741+
$typesMap['iterable'] = 'array' !== $typesMap['array'] ? $typesMap['array'] : 'iterable';
742+
unset($typesMap['array'], $typesMap['Traversable'], $typesMap['\Traversable']);
743+
} elseif ('array' !== $typesMap['array'] && isset(self::$returnTypes[$method->class][$method->name])) {
744+
return;
745+
}
615746
}
616747

617748
if (isset($typesMap['array']) && isset($typesMap['iterable'])) {
@@ -630,7 +761,7 @@ private function setReturnType(string $types, \ReflectionMethod $method, ?string
630761
} elseif ('null' === $normalizedType) {
631762
$normalizedType = $t;
632763
$returnType = $t;
633-
} elseif ($n !== $normalizedType) {
764+
} elseif ($n !== $normalizedType || !preg_match('/^\\\\?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $n)) {
634765
// ignore multi-types return declarations
635766
return;
636767
}
@@ -680,7 +811,7 @@ private function normalizeType(string $type, string $class, ?string $parent): st
680811
/**
681812
* Utility method to add @return annotations to the Symfony code-base where it triggers a self-deprecations.
682813
*/
683-
private static function patchMethod(\ReflectionMethod $method, string $returnType, string $declaringFile)
814+
private function patchMethod(\ReflectionMethod $method, string $returnType, string $declaringFile, string $normalizedType)
684815
{
685816
static $patchedMethods = [];
686817
static $useStatements = [];
@@ -690,8 +821,10 @@ private static function patchMethod(\ReflectionMethod $method, string $returnTyp
690821
}
691822

692823
$patchedMethods[$file][$startLine] = true;
693-
$patchedMethods[$file][0] = $patchedMethods[$file][0] ?? 0;
694-
$startLine += $patchedMethods[$file][0] - 2;
824+
$fileOffset = self::$fileOffsets[$file] ?? 0;
825+
$startLine += $fileOffset - 2;
826+
$nullable = '?' === $normalizedType[0] ? '?' : '';
827+
$normalizedType = ltrim($normalizedType, '?');
695828
$returnType = explode('|', $returnType);
696829
$code = file($file);
697830

@@ -737,32 +870,42 @@ private static function patchMethod(\ReflectionMethod $method, string $returnTyp
737870
if (!isset($useMap[$alias])) {
738871
$useStatements[$file][2][$alias] = $type;
739872
$code[$useOffset] = "use $type;\n".$code[$useOffset];
740-
++$patchedMethods[$file][0];
873+
++$fileOffset;
741874
} elseif ($useMap[$alias] !== $type) {
742875
$alias .= 'FIXME';
743876
$useStatements[$file][2][$alias] = $type;
744877
$code[$useOffset] = "use $type as $alias;\n".$code[$useOffset];
745-
++$patchedMethods[$file][0];
878+
++$fileOffset;
746879
}
747880

748881
$returnType[$i] = null !== $format ? sprintf($format, $alias) : $alias;
882+
883+
if (!isset(self::SPECIAL_RETURN_TYPES[$normalizedType]) && !isset(self::SPECIAL_RETURN_TYPES[$returnType[$i]])) {
884+
$normalizedType = $returnType[$i];
885+
}
749886
}
750887

751-
$returnType = implode('|', $returnType);
888+
if ('docblock' === ($this->patchTypes['force'] ?? null) || ('object' === $normalizedType && ($this->patchTypes['php71-compat'] ?? false))) {
889+
$returnType = implode('|', $returnType);
752890

753-
if ($method->getDocComment()) {
754-
$code[$startLine] = " * @return $returnType\n".$code[$startLine];
755-
} else {
756-
$code[$startLine] .= <<<EOTXT
891+
if ($method->getDocComment()) {
892+
$code[$startLine] = " * @return $returnType\n".$code[$startLine];
893+
} else {
894+
$code[$startLine] .= <<<EOTXT
757895
/**
758896
* @return $returnType
759897
*/
760898
761899
EOTXT;
900+
}
901+
902+
$fileOffset += substr_count($code[$startLine], "\n") - 1;
762903
}
763904

764-
$patchedMethods[$file][0] += substr_count($code[$startLine], "\n") - 1;
905+
self::$fileOffsets[$file] = $fileOffset;
765906
file_put_contents($file, $code);
907+
908+
$this->fixReturnStatements($method, $nullable.$normalizedType);
766909
}
767910

768911
private static function getUseStatements(string $file): array
@@ -808,15 +951,25 @@ private static function getUseStatements(string $file): array
808951
return [$namespace, $useOffset, $useMap];
809952
}
810953

811-
private static function fixReturnStatements(\ReflectionMethod $method, string $returnType)
954+
private function fixReturnStatements(\ReflectionMethod $method, string $returnType)
812955
{
956+
if (($this->patchTypes['php71-compat'] ?? false) && 'object' === ltrim($returnType, '?') && 'docblock' !== ($this->patchTypes['force'] ?? null)) {
957+
return;
958+
}
959+
813960
if (!file_exists($file = $method->getFileName())) {
814961
return;
815962
}
816963

817964
$fixedCode = $code = file($file);
818-
$end = $method->getEndLine();
819-
for ($i = $method->getStartLine(); $i < $end; ++$i) {
965+
$i = (self::$fileOffsets[$file] ?? 0) + $method->getStartLine();
966+
967+
if ('?' !== $returnType && 'docblock' !== ($this->patchTypes['force'] ?? null)) {
968+
$fixedCode[$i - 1] = preg_replace('/\)(;?\n)/', "): $returnType\\1", $code[$i - 1]);
969+
}
970+
971+
$end = $method->isGenerator() ? $i : $method->getEndLine();
972+
for (; $i < $end; ++$i) {
820973
if ('void' === $returnType) {
821974
$fixedCode[$i] = str_replace(' return null;', ' return;', $code[$i]);
822975
} elseif ('mixed' === $returnType || '?' === $returnType[0]) {

0 commit comments

Comments
 (0)