mirror of
https://github.com/symfony/error-handler.git
synced 2026-03-24 00:02:09 +01:00
[ErrorHandler] Allow namespace remapping in DebugClassLoader to relax the "same vendor" constraint
This commit is contained in:
committed by
Nicolas Grekas
parent
4bbc6c920b
commit
81c7c035a2
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
8.1
|
||||
---
|
||||
|
||||
* Add argument `$deprecationsNamespacesMapping` to `DebugClassLoader::enable()` to configure namespace-to-vendor remapping for deprecation checks
|
||||
|
||||
7.3
|
||||
---
|
||||
|
||||
|
||||
@@ -139,6 +139,16 @@ class DebugClassLoader
|
||||
private static array $methodTraits = [];
|
||||
private static array $fileOffsets = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string>|null Re-mapping configuration for vendor comparison. Maps namespace prefixes to the value to be used for comparison.
|
||||
*/
|
||||
private static ?array $namespaceRemappings = null;
|
||||
|
||||
/**
|
||||
* @var array<string, true|string> Caches vendor comparison strings. Maps FQCNs to vendor prefixes; true = root namespace (matches every vendor).
|
||||
*/
|
||||
private static array $vendorPrefixCache = [];
|
||||
|
||||
public function __construct(callable $classLoader)
|
||||
{
|
||||
$this->classLoader = $classLoader;
|
||||
@@ -185,9 +195,20 @@ class DebugClassLoader
|
||||
|
||||
/**
|
||||
* Wraps all autoloaders.
|
||||
*
|
||||
* @param array<string, string>|null $deprecationsNamespacesMapping Overrides the vendor-boundary detection used to
|
||||
* decide whether deprecation notices are emitted.
|
||||
* Each key is a fully-qualified class name or
|
||||
* namespace prefix of the class being loaded;
|
||||
* the corresponding value is the vendor string it
|
||||
* will be compared against instead of its natural
|
||||
* first namespace segment.
|
||||
* Pass null (default) to use the first namespace segment as vendor name.
|
||||
*/
|
||||
public static function enable(): void
|
||||
public static function enable(/* ?array $deprecationsNamespacesMapping = null */): void
|
||||
{
|
||||
$deprecationsNamespacesMapping = 1 <= \func_num_args() ? func_get_arg(0) : null;
|
||||
|
||||
// Ensures we don't hit https://bugs.php.net/42098
|
||||
class_exists(ErrorHandler::class);
|
||||
class_exists(LogLevel::class);
|
||||
@@ -196,6 +217,8 @@ class DebugClassLoader
|
||||
return;
|
||||
}
|
||||
|
||||
self::$namespaceRemappings = $deprecationsNamespacesMapping;
|
||||
|
||||
foreach ($functions as $function) {
|
||||
spl_autoload_unregister($function);
|
||||
}
|
||||
@@ -218,6 +241,9 @@ class DebugClassLoader
|
||||
return;
|
||||
}
|
||||
|
||||
self::$namespaceRemappings = null;
|
||||
self::$vendorPrefixCache = [];
|
||||
|
||||
foreach ($functions as $function) {
|
||||
spl_autoload_unregister($function);
|
||||
}
|
||||
@@ -376,19 +402,11 @@ class DebugClassLoader
|
||||
}
|
||||
$deprecations = [];
|
||||
|
||||
// $className is a human-readable name used in deprecation messages: for anonymous classes
|
||||
// (whose internal name contains "@anonymous\0" followed by a file path) it is replaced
|
||||
// by a display-friendly form such as "ParentClass@anonymous"; for named classes it equals $class.
|
||||
$className = str_contains($class, "@anonymous\0") ? (get_parent_class($class) ?: key(class_implements($class)) ?: 'class').'@anonymous' : $class;
|
||||
|
||||
// Don't trigger deprecations for classes in the same vendor
|
||||
if ($class !== $className) {
|
||||
$vendor = $refl->getFileName() && preg_match('/^namespace ([^;\\\\\s]++)[;\\\\]/m', @file_get_contents($refl->getFileName()) ?: '', $vendor) ? $vendor[1].'\\' : '';
|
||||
$vendorLen = \strlen($vendor);
|
||||
} elseif (2 > $vendorLen = 1 + (strpos($class, '\\') ?: strpos($class, '_'))) {
|
||||
$vendorLen = 0;
|
||||
$vendor = '';
|
||||
} else {
|
||||
$vendor = str_replace('_', '\\', substr($class, 0, $vendorLen));
|
||||
}
|
||||
|
||||
$parent = get_parent_class($class) ?: null;
|
||||
self::$returnTypes[$class] = [];
|
||||
$classIsTemplate = false;
|
||||
@@ -432,13 +450,13 @@ class DebugClassLoader
|
||||
if (!isset(self::$checkedClasses[$use])) {
|
||||
$this->checkClass($use);
|
||||
}
|
||||
if (isset(self::$deprecated[$use]) && strncmp($vendor, str_replace('_', '\\', $use), $vendorLen) && !isset(self::$deprecated[$class])) {
|
||||
if (isset(self::$deprecated[$use]) && !isset(self::$deprecated[$class]) && !$this->areFromTheSameVendor($class, $use)) {
|
||||
$type = class_exists($class, false) ? 'class' : (interface_exists($class, false) ? 'interface' : 'trait');
|
||||
$verb = class_exists($use, false) || interface_exists($class, false) ? 'extends' : (interface_exists($use, false) ? 'implements' : 'uses');
|
||||
|
||||
$deprecations[] = \sprintf('The "%s" %s %s "%s" that is deprecated%s', $className, $type, $verb, $use, self::$deprecated[$use]);
|
||||
}
|
||||
if (isset(self::$internal[$use]) && strncmp($vendor, str_replace('_', '\\', $use), $vendorLen)) {
|
||||
if (isset(self::$internal[$use]) && !$this->areFromTheSameVendor($class, $use)) {
|
||||
$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], $className);
|
||||
}
|
||||
if (isset(self::$method[$use])) {
|
||||
@@ -449,7 +467,7 @@ class DebugClassLoader
|
||||
self::$method[$class] = self::$method[$use];
|
||||
}
|
||||
} elseif (!$refl->isInterface()) {
|
||||
if (!strncmp($vendor, str_replace('_', '\\', $use), $vendorLen)
|
||||
if ($this->areFromTheSameVendor($class, $use)
|
||||
&& str_starts_with($className, 'Symfony\\')
|
||||
&& (!class_exists(InstalledVersions::class)
|
||||
|| 'symfony/symfony' !== InstalledVersions::getRootPackage()['name'])
|
||||
@@ -517,15 +535,9 @@ class DebugClassLoader
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === $ns = self::$methodTraits[$method->getFileName()][$method->getStartLine()] ?? null) {
|
||||
$ns = $vendor;
|
||||
$len = $vendorLen;
|
||||
} elseif (2 > $len = 1 + (strpos($ns, '\\') ?: strpos($ns, '_'))) {
|
||||
$len = 0;
|
||||
$ns = '';
|
||||
} else {
|
||||
$ns = str_replace('_', '\\', substr($ns, 0, $len));
|
||||
}
|
||||
// If this method was introduced via a trait, use the trait's vendor for checks
|
||||
// rather than the containing class' vendor.
|
||||
$traitClass = self::$methodTraits[$method->getFileName()][$method->getStartLine()] ?? null;
|
||||
|
||||
if ($parent && isset(self::$finalMethods[$parent][$method->name])) {
|
||||
[$declaringClass, $message] = self::$finalMethods[$parent][$method->name];
|
||||
@@ -534,7 +546,7 @@ class DebugClassLoader
|
||||
|
||||
if (isset(self::$internalMethods[$class][$method->name])) {
|
||||
[$declaringClass, $message] = self::$internalMethods[$class][$method->name];
|
||||
if (strncmp($ns, $declaringClass, $len)) {
|
||||
if (!$this->areFromTheSameVendor($traitClass ?? $class, $declaringClass)) {
|
||||
$deprecations[] = \sprintf('The "%s::%s()" method is considered internal%s It may change without further notice. You should not extend it from "%s".', $declaringClass, $method->name, $message, $className);
|
||||
}
|
||||
}
|
||||
@@ -586,7 +598,7 @@ class DebugClassLoader
|
||||
if ($canAddReturnType && 'docblock' !== $this->patchTypes['force']) {
|
||||
$this->patchMethod($method, $returnType, $declaringFile, $normalizedType);
|
||||
}
|
||||
if (!isset($doc['deprecated']) && strncmp($ns, $declaringClass, $len)) {
|
||||
if (!isset($doc['deprecated']) && !$this->areFromTheSameVendor($traitClass ?? $class, $declaringClass)) {
|
||||
if ('docblock' === $this->patchTypes['force']) {
|
||||
$this->patchMethod($method, $returnType, $declaringFile, $normalizedType);
|
||||
} elseif ('' !== $declaringClass && $this->patchTypes['deprecations']) {
|
||||
@@ -803,6 +815,60 @@ class DebugClassLoader
|
||||
return $ownInterfaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class The class being loaded and inspected for deprecation violations
|
||||
* @param string $use The parent class, interface, or trait it extends, implements, or uses
|
||||
*/
|
||||
private function areFromTheSameVendor(string $class, string $use): bool
|
||||
{
|
||||
$vendor = self::$vendorPrefixCache[$class] ?? $this->getVendorEntry($class);
|
||||
|
||||
return true === $vendor || $vendor === (self::$vendorPrefixCache[$use] ?? $this->getVendorEntry($use));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the vendor string for a class, computing and caching it if necessary. Takes
|
||||
* remapping into account, see {@see enable()}.
|
||||
*
|
||||
* @return true|string the vendor prefix to consider; true when the class is in the root namespace
|
||||
* (matches every vendor)
|
||||
*/
|
||||
private function getVendorEntry(string $class): bool|string
|
||||
{
|
||||
if (isset(self::$vendorPrefixCache[$class])) {
|
||||
return self::$vendorPrefixCache[$class];
|
||||
}
|
||||
|
||||
// Anonymous classes carry a file path in their internal name instead of a namespace,
|
||||
// so the vendor prefix must be derived from the namespace declared in their source file.
|
||||
// Named classes use the class name itself as the lookup key.
|
||||
if (str_contains($class, "@anonymous\0")) {
|
||||
$refl = new \ReflectionClass($class);
|
||||
$lookupKey = $refl->getFileName() && preg_match('/^namespace ([^;\\\\\s]++)[;\\\\]/m', @file_get_contents($refl->getFileName()) ?: '', $m) ? $m[1] : '';
|
||||
} else {
|
||||
$lookupKey = $class;
|
||||
}
|
||||
|
||||
if (\is_array(self::$namespaceRemappings)) {
|
||||
// Find longest namespace prefix for which a mapping exists
|
||||
$mappedNamespace = $lookupKey;
|
||||
while (!isset(self::$namespaceRemappings[$mappedNamespace]) && false !== $pos = strrpos($mappedNamespace, '\\')) {
|
||||
$mappedNamespace = substr($mappedNamespace, 0, $pos);
|
||||
}
|
||||
if (isset(self::$namespaceRemappings[$mappedNamespace])) {
|
||||
return self::$vendorPrefixCache[$class] = self::$namespaceRemappings[$mappedNamespace];
|
||||
}
|
||||
}
|
||||
|
||||
$sep = strpos($lookupKey, '\\') ?: strpos($lookupKey, '_');
|
||||
if (!$sep) {
|
||||
// The class is in the root namespace: it matches every vendor.
|
||||
return self::$vendorPrefixCache[$class] = true;
|
||||
}
|
||||
|
||||
return self::$vendorPrefixCache[$class] = substr($lookupKey, 0, $sep);
|
||||
}
|
||||
|
||||
private function setReturnType(string $types, string $class, string $method, string $filename, ?string $parent, ?\ReflectionType $returnType = null): void
|
||||
{
|
||||
if ('__construct' === $method) {
|
||||
|
||||
@@ -15,7 +15,9 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bridge\ErrorHandler\Tests\Fixtures\ExtendsDeprecatedParent;
|
||||
use Symfony\Component\DependencyInjection\Tests\Fixtures\DeprecatedClass;
|
||||
use Symfony\Component\ErrorHandler\DebugClassLoader;
|
||||
use Symfony\Component\ErrorHandler\Tests\Fixtures\ExtendsDeprecatedClassInTheSameVendor;
|
||||
|
||||
class DebugClassLoaderTest extends TestCase
|
||||
{
|
||||
@@ -359,6 +361,90 @@ class DebugClassLoaderTest extends TestCase
|
||||
$this->assertTrue(class_exists(Fixtures\DefinitionInEvaluatedCode::class, true));
|
||||
}
|
||||
|
||||
#[RunInSeparateProcess]
|
||||
#[DataProvider('provideExposeDeprecations')]
|
||||
public function testExposeDeprecations(bool $expectDeprecation, ?array $deprecationsNamespacesMapping)
|
||||
{
|
||||
DebugClassLoader::enable($deprecationsNamespacesMapping);
|
||||
|
||||
$deprecations = [];
|
||||
set_error_handler(static function ($type, $msg) use (&$deprecations) { $deprecations[] = $msg; });
|
||||
$e = error_reporting(\E_USER_DEPRECATED);
|
||||
|
||||
new ExtendsDeprecatedClassInTheSameVendor();
|
||||
|
||||
error_reporting($e);
|
||||
restore_error_handler();
|
||||
|
||||
$this->assertSame($expectDeprecation ? [
|
||||
'The "Symfony\Component\ErrorHandler\Tests\Fixtures\ExtendsDeprecatedClassInTheSameVendor" class extends "Symfony\Component\ErrorHandler\Tests\Fixtures\DeprecatedClass" that is deprecated but this is a test deprecation notice.',
|
||||
] : [], $deprecations);
|
||||
}
|
||||
|
||||
public static function provideExposeDeprecations(): array
|
||||
{
|
||||
return [
|
||||
[false, null], // default current behavior -> should not be exposed
|
||||
[false, []], // no matching (empty array) -> should not be exposed
|
||||
[false, ['No\Matching' => 'foo']], // no matching -> should not be exposed
|
||||
[true, [ExtendsDeprecatedClassInTheSameVendor::class => 'foo']], // only $class matched -> different vendors -> should be exposed
|
||||
[false, ['Symfony\Component\ErrorHandler\Tests\Fixtures' => 'foo']], // both $class and $use matched to same vendor -> should not be exposed
|
||||
[false, ['Symfony\Component\ErrorHandler' => 'foo']], // both $class and $use matched to same vendor -> should not be exposed
|
||||
[false, ['Symfony' => 'foo']], // both $class and $use matched to same vendor -> should not be exposed
|
||||
[true, [ExtendsDeprecatedClassInTheSameVendor::class => 'foo', DeprecatedClass::class => 'bar']], // both matched but to different vendors -> should be exposed
|
||||
];
|
||||
}
|
||||
|
||||
#[RunInSeparateProcess]
|
||||
#[DataProvider('provideMuteDeprecations')]
|
||||
public function testMuteDeprecations(bool $expectDeprecation, ?array $deprecationsNamespacesMapping)
|
||||
{
|
||||
DebugClassLoader::enable($deprecationsNamespacesMapping);
|
||||
|
||||
$deprecations = [];
|
||||
set_error_handler(static function ($type, $msg) use (&$deprecations) { $deprecations[] = $msg; });
|
||||
$e = error_reporting(\E_USER_DEPRECATED);
|
||||
|
||||
class_exists('Test\\'.__NAMESPACE__.'\DeprecatedParentClass', true);
|
||||
|
||||
error_reporting($e);
|
||||
restore_error_handler();
|
||||
|
||||
$this->assertSame($expectDeprecation ? [
|
||||
'The "Test\Symfony\Component\ErrorHandler\Tests\DeprecatedParentClass" class extends "Symfony\Component\ErrorHandler\Tests\Fixtures\DeprecatedClass" that is deprecated but this is a test deprecation notice.',
|
||||
] : [], $deprecations);
|
||||
}
|
||||
|
||||
public static function provideMuteDeprecations(): array
|
||||
{
|
||||
return [
|
||||
[true, null], // default current behavior -> should not be muted
|
||||
[true, []], // no matching (empty array) -> should not be muted
|
||||
[true, ['No\Matching' => 'foo']], // no matching -> should not be muted
|
||||
[true, ['Test' => 'No\Matching']], // only $class matched, vendors differ -> should not be muted
|
||||
[false, ['Test\\'.__NAMESPACE__.'\DeprecatedParentClass' => 'x', 'Symfony\Component\ErrorHandler\Tests\Fixtures\DeprecatedClass' => 'x']], // both matched to same vendor via FQCN -> should be muted
|
||||
[false, ['Test\\'.__NAMESPACE__ => 'x', 'Symfony\Component\ErrorHandler\Tests\Fixtures' => 'x']], // both matched to same vendor via namespace prefix -> should be muted
|
||||
[false, ['Test' => 'Symfony']], // $class matched to 'Symfony', $use default first segment 'Symfony' -> same vendor -> should be muted
|
||||
[true, ['Test' => 'one', 'Symfony' => 'two']], // both matched but to different vendors -> should not be muted
|
||||
];
|
||||
}
|
||||
|
||||
public function testRootNamespaceDontTriggerDeprecations()
|
||||
{
|
||||
$deprecations = [];
|
||||
set_error_handler(static function ($type, $msg) use (&$deprecations) { $deprecations[] = $msg; });
|
||||
$e = error_reporting(\E_USER_DEPRECATED);
|
||||
|
||||
require __DIR__.'/Fixtures/RootNamespace.php';
|
||||
|
||||
spl_autoload_call(\RootNamespace::class);
|
||||
|
||||
error_reporting($e);
|
||||
restore_error_handler();
|
||||
|
||||
$this->assertSame([], $deprecations);
|
||||
}
|
||||
|
||||
public function testReturnType()
|
||||
{
|
||||
$deprecations = [];
|
||||
|
||||
7
Tests/Fixtures/ExtendsDeprecatedClassInTheSameVendor.php
Normal file
7
Tests/Fixtures/ExtendsDeprecatedClassInTheSameVendor.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Symfony\Component\ErrorHandler\Tests\Fixtures;
|
||||
|
||||
class ExtendsDeprecatedClassInTheSameVendor extends DeprecatedClass
|
||||
{
|
||||
}
|
||||
7
Tests/Fixtures/RootNamespace.php
Normal file
7
Tests/Fixtures/RootNamespace.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\ErrorHandler\Tests\Fixtures\DeprecatedClass;
|
||||
|
||||
class RootNamespace extends DeprecatedClass
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user