Skip to content

Commit 10fc13e

Browse files
fancywebnicolas-grekas
authored andcommitted
[ErrorHandler] Handle return types in DebugClassLoader
1 parent 507223d commit 10fc13e

File tree

8 files changed

+602
-12
lines changed

8 files changed

+602
-12
lines changed

src/Symfony/Component/ErrorHandler/DebugClassLoader.php

Lines changed: 271 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,43 @@
2727
*/
2828
class DebugClassLoader
2929
{
30+
private const SPECIAL_RETURN_TYPES = [
31+
'mixed' => 'mixed',
32+
'void' => 'void',
33+
'null' => 'null',
34+
'resource' => 'resource',
35+
'static' => 'object',
36+
'$this' => 'object',
37+
'boolean' => 'bool',
38+
'true' => 'bool',
39+
'false' => 'bool',
40+
'integer' => 'int',
41+
'array' => 'array',
42+
'bool' => 'bool',
43+
'callable' => 'callable',
44+
'float' => 'float',
45+
'int' => 'integer',
46+
'iterable' => 'iterable',
47+
'object' => 'object',
48+
'string' => 'string',
49+
'self' => 'self',
50+
'parent' => 'parent',
51+
];
52+
53+
private const BUILTIN_RETURN_TYPES = [
54+
'void' => true,
55+
'array' => true,
56+
'bool' => true,
57+
'callable' => true,
58+
'float' => true,
59+
'int' => true,
60+
'iterable' => true,
61+
'object' => true,
62+
'string' => true,
63+
'self' => true,
64+
'parent' => true,
65+
];
66+
3067
private $classLoader;
3168
private $isFinder;
3269
private $loaded = [];
@@ -40,6 +77,8 @@ class DebugClassLoader
4077
private static $annotatedParameters = [];
4178
private static $darwinCache = ['/' => ['/', []]];
4279
private static $method = [];
80+
private static $returnTypes = [];
81+
private static $methodTraits = [];
4382

4483
public function __construct(callable $classLoader)
4584
{
@@ -218,11 +257,11 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
218257
$deprecations = [];
219258

220259
// Don't trigger deprecations for classes in the same vendor
221-
if (2 > $len = 1 + (strpos($class, '\\') ?: strpos($class, '_'))) {
222-
$len = 0;
223-
$ns = '';
260+
if (2 > $vendorLen = 1 + (strpos($class, '\\') ?: strpos($class, '_'))) {
261+
$vendorLen = 0;
262+
$vendor = '';
224263
} else {
225-
$ns = str_replace('_', '\\', substr($class, 0, $len));
264+
$vendor = str_replace('_', '\\', substr($class, 0, $vendorLen));
226265
}
227266

228267
// Detect annotations on the class
@@ -252,7 +291,7 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
252291
}
253292
}
254293

255-
$parent = get_parent_class($class);
294+
$parent = get_parent_class($class) ?: null;
256295
$parentAndOwnInterfaces = $this->getOwnInterfaces($class, $parent);
257296
if ($parent) {
258297
$parentAndOwnInterfaces[$parent] = $parent;
@@ -271,13 +310,13 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
271310
if (!isset(self::$checkedClasses[$use])) {
272311
$this->checkClass($use);
273312
}
274-
if (isset(self::$deprecated[$use]) && strncmp($ns, str_replace('_', '\\', $use), $len) && !isset(self::$deprecated[$class])) {
313+
if (isset(self::$deprecated[$use]) && strncmp($vendor, str_replace('_', '\\', $use), $vendorLen) && !isset(self::$deprecated[$class])) {
275314
$type = class_exists($class, false) ? 'class' : (interface_exists($class, false) ? 'interface' : 'trait');
276315
$verb = class_exists($use, false) || interface_exists($class, false) ? 'extends' : (interface_exists($use, false) ? 'implements' : 'uses');
277316

278317
$deprecations[] = sprintf('The "%s" %s %s "%s" that is deprecated%s.', $class, $type, $verb, $use, self::$deprecated[$use]);
279318
}
280-
if (isset(self::$internal[$use]) && strncmp($ns, str_replace('_', '\\', $use), $len)) {
319+
if (isset(self::$internal[$use]) && strncmp($vendor, str_replace('_', '\\', $use), $vendorLen)) {
281320
$deprecations[] = sprintf('The "%s" %s is considered internal%s. It may change without further notice. You should not use it from "%s".', $use, class_exists($use, false) ? 'class' : (interface_exists($use, false) ? 'interface' : 'trait'), self::$internal[$use], $class);
282321
}
283322
if (isset(self::$method[$use])) {
@@ -305,15 +344,24 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
305344
}
306345

307346
if (trait_exists($class)) {
347+
$file = $refl->getFileName();
348+
349+
foreach ($refl->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) as $method) {
350+
if ($method->getFileName() === $file) {
351+
self::$methodTraits[$file][$method->getStartLine()] = $class;
352+
}
353+
}
354+
308355
return $deprecations;
309356
}
310357

311-
// Inherit @final, @internal and @param annotations for methods
358+
// Inherit @final, @internal, @param and @return annotations for methods
312359
self::$finalMethods[$class] = [];
313360
self::$internalMethods[$class] = [];
314361
self::$annotatedParameters[$class] = [];
362+
self::$returnTypes[$class] = [];
315363
foreach ($parentAndOwnInterfaces as $use) {
316-
foreach (['finalMethods', 'internalMethods', 'annotatedParameters'] as $property) {
364+
foreach (['finalMethods', 'internalMethods', 'annotatedParameters', 'returnTypes'] as $property) {
317365
if (isset(self::${$property}[$use])) {
318366
self::${$property}[$class] = self::${$property}[$class] ? self::${$property}[$use] + self::${$property}[$class] : self::${$property}[$use];
319367
}
@@ -325,6 +373,16 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
325373
continue;
326374
}
327375

376+
if (null === $ns = self::$methodTraits[$method->getFileName()][$method->getStartLine()] ?? null) {
377+
$ns = $vendor;
378+
$len = $vendorLen;
379+
} elseif (2 > $len = 1 + (strpos($ns, '\\') ?: strpos($ns, '_'))) {
380+
$len = 0;
381+
$ns = '';
382+
} else {
383+
$ns = str_replace('_', '\\', substr($ns, 0, $len));
384+
}
385+
328386
if ($parent && isset(self::$finalMethods[$parent][$method->name])) {
329387
list($declaringClass, $message) = self::$finalMethods[$parent][$method->name];
330388
$deprecations[] = sprintf('The "%s::%s()" method is considered final%s. It may change without further notice as of its next major version. You should not extend it from "%s".', $declaringClass, $method->name, $message, $class);
@@ -353,10 +411,26 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array
353411
}
354412
}
355413

414+
if (isset(self::$returnTypes[$class][$method->name]) && !$method->hasReturnType() && !($doc && preg_match('/\n\s+\* @return +(\S+)/', $doc))) {
415+
list($returnType, $declaringClass, $declaringFile) = self::$returnTypes[$class][$method->name];
416+
417+
if (strncmp($ns, $declaringClass, $len)) {
418+
//if (0 === strpos($class, 'Symfony\\')) {
419+
// self::patchMethod($method, $returnType, $declaringFile);
420+
//}
421+
422+
$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, $returnType, $class);
423+
}
424+
}
425+
356426
if (!$doc) {
357427
continue;
358428
}
359429

430+
if (!$method->hasReturnType() && false !== strpos($doc, '@return') && preg_match('/\n\s+\* @return +(\S+)/', $doc, $matches)) {
431+
$this->setReturnType($matches[1], $method, $parent);
432+
}
433+
360434
$finalOrInternal = false;
361435

362436
foreach (['final', 'internal'] as $annotation) {
@@ -496,11 +570,9 @@ private function darwinRealpath(string $real): string
496570
/**
497571
* `class_implements` includes interfaces from the parents so we have to manually exclude them.
498572
*
499-
* @param string|false $parent
500-
*
501573
* @return string[]
502574
*/
503-
private function getOwnInterfaces(string $class, $parent): array
575+
private function getOwnInterfaces(string $class, ?string $parent): array
504576
{
505577
$ownInterfaces = class_implements($class, false);
506578

@@ -518,4 +590,191 @@ private function getOwnInterfaces(string $class, $parent): array
518590

519591
return $ownInterfaces;
520592
}
593+
594+
private function setReturnType(string $types, \ReflectionMethod $method, ?string $parent): void
595+
{
596+
$nullable = false;
597+
$typesMap = [];
598+
foreach (explode('|', $types) as $t) {
599+
$t = $this->normalizeType($t, $method->class, $parent);
600+
$typesMap[strtolower($t)] = $t;
601+
}
602+
603+
if (isset($typesMap['array']) && isset($typesMap['iterable'])) {
604+
if ('[]' === substr($typesMap['array'], -2)) {
605+
$typesMap['iterable'] = $typesMap['array'];
606+
}
607+
unset($typesMap['array']);
608+
}
609+
610+
$normalizedType = key($typesMap);
611+
$returnType = current($typesMap);
612+
613+
foreach ($typesMap as $n => $t) {
614+
if ('null' === $n) {
615+
$nullable = true;
616+
} elseif ('null' === $normalizedType) {
617+
$normalizedType = $t;
618+
$returnType = $t;
619+
} elseif ($n !== $normalizedType) {
620+
// ignore multi-types return declarations
621+
return;
622+
}
623+
}
624+
625+
if ('void' === $normalizedType) {
626+
$nullable = false;
627+
} elseif (!isset(self::BUILTIN_RETURN_TYPES[$normalizedType]) && isset(self::SPECIAL_RETURN_TYPES[$normalizedType])) {
628+
// ignore other special return types
629+
return;
630+
}
631+
632+
if ($nullable) {
633+
$returnType = '?'.$returnType;
634+
}
635+
636+
self::$returnTypes[$method->class][$method->name] = [$returnType, $method->class, $method->getFileName()];
637+
}
638+
639+
private function normalizeType(string $type, string $class, ?string $parent): string
640+
{
641+
if (isset(self::SPECIAL_RETURN_TYPES[$lcType = strtolower($type)])) {
642+
if ('parent' === $lcType = self::SPECIAL_RETURN_TYPES[$lcType]) {
643+
$lcType = null !== $parent ? '\\'.$parent : 'parent';
644+
} elseif ('self' === $lcType) {
645+
$lcType = '\\'.$class;
646+
}
647+
648+
return $lcType;
649+
}
650+
651+
if ('[]' === substr($type, -2)) {
652+
return 'array';
653+
}
654+
655+
if (preg_match('/^(array|iterable|callable) *[<(]/', $lcType, $m)) {
656+
return $m[1];
657+
}
658+
659+
// We could resolve "use" statements to return the FQDN
660+
// but this would be too expensive for a runtime checker
661+
662+
return $type;
663+
}
664+
665+
/**
666+
* Utility method to add @return annotations to the Symfony code-base where it triggers a self-deprecations.
667+
*/
668+
private static function patchMethod(\ReflectionMethod $method, string $returnType, string $declaringFile)
669+
{
670+
static $patchedMethods = [];
671+
static $useStatements = [];
672+
673+
if (!file_exists($file = $method->getFileName()) || isset($patchedMethods[$file][$startLine = $method->getStartLine()])) {
674+
return;
675+
}
676+
677+
$patchedMethods[$file][$startLine] = true;
678+
$patchedMethods[$file][0] = $patchedMethods[$file][0] ?? 0;
679+
$startLine += $patchedMethods[$file][0] - 2;
680+
$nullable = '?' === $returnType[0] ? '?' : '';
681+
$returnType = ltrim($returnType, '?');
682+
$code = file($file);
683+
684+
if (!isset(self::BUILTIN_RETURN_TYPES[$returnType]) && ('\\' !== $returnType[0] || $p = strrpos($returnType, '\\', 1))) {
685+
list($namespace, $useOffset, $useMap) = $useStatements[$file] ?? $useStatements[$file] = self::getUseStatements($file);
686+
687+
if ('\\' !== $returnType[0]) {
688+
list($declaringNamespace, , $declaringUseMap) = $useStatements[$declaringFile] ?? $useStatements[$declaringFile] = self::getUseStatements($declaringFile);
689+
690+
$p = strpos($returnType, '\\', 1);
691+
$alias = $p ? substr($returnType, 0, $p) : $returnType;
692+
693+
if (isset($declaringUseMap[$alias])) {
694+
$returnType = '\\'.$declaringUseMap[$alias].($p ? substr($returnType, $p) : '');
695+
} else {
696+
$returnType = '\\'.$declaringNamespace.$returnType;
697+
}
698+
699+
$p = strrpos($returnType, '\\', 1);
700+
}
701+
702+
$alias = substr($returnType, 1 + $p);
703+
$returnType = substr($returnType, 1);
704+
705+
if (!isset($useMap[$alias]) && (class_exists($c = $namespace.$alias) || interface_exists($c) || trait_exists($c))) {
706+
$useMap[$alias] = $c;
707+
}
708+
709+
if (!isset($useMap[$alias])) {
710+
$useStatements[$file][2][$alias] = $returnType;
711+
$code[$useOffset] = "use $returnType;\n".$code[$useOffset];
712+
++$patchedMethods[$file][0];
713+
} elseif ($useMap[$alias] !== $returnType) {
714+
$alias .= 'FIXME';
715+
$useStatements[$file][2][$alias] = $returnType;
716+
$code[$useOffset] = "use $returnType as $alias;\n".$code[$useOffset];
717+
++$patchedMethods[$file][0];
718+
}
719+
720+
$returnType = $alias;
721+
}
722+
723+
if ($method->getDocComment()) {
724+
$code[$startLine] = " * @return $nullable$returnType\n".$code[$startLine];
725+
} else {
726+
$code[$startLine] .= <<<EOTXT
727+
/**
728+
* @return $nullable$returnType
729+
*/
730+
731+
EOTXT;
732+
}
733+
734+
$patchedMethods[$file][0] += substr_count($code[$startLine], "\n") - 1;
735+
file_put_contents($file, $code);
736+
}
737+
738+
private static function getUseStatements(string $file): array
739+
{
740+
$namespace = '';
741+
$useMap = [];
742+
$useOffset = 0;
743+
744+
if (!file_exists($file)) {
745+
return [$namespace, $useOffset, $useMap];
746+
}
747+
748+
$file = file($file);
749+
750+
for ($i = 0; $i < \count($file); ++$i) {
751+
if (preg_match('/^(class|interface|trait|abstract) /', $file[$i])) {
752+
break;
753+
}
754+
755+
if (0 === strpos($file[$i], 'namespace ')) {
756+
$namespace = substr($file[$i], \strlen('namespace '), -2).'\\';
757+
$useOffset = $i + 2;
758+
}
759+
760+
if (0 === strpos($file[$i], 'use ')) {
761+
$useOffset = $i;
762+
763+
for (; 0 === strpos($file[$i], 'use '); ++$i) {
764+
$u = explode(' as ', substr($file[$i], 4, -2), 2);
765+
766+
if (1 === \count($u)) {
767+
$p = strrpos($u[0], '\\');
768+
$useMap[substr($u[0], false !== $p ? 1 + $p : 0)] = $u[0];
769+
} else {
770+
$useMap[$u[1]] = $u[0];
771+
}
772+
}
773+
774+
break;
775+
}
776+
}
777+
778+
return [$namespace, $useOffset, $useMap];
779+
}
521780
}

0 commit comments

Comments
 (0)