From df6c0dfd9963fa04dd52935769925bb37f14a067 Mon Sep 17 00:00:00 2001 From: yavon007 Date: Mon, 14 Sep 2026 17:20:31 +0800 Subject: [PATCH] Preserve get_called_class in directly compiled methods (#107) * fix(compiler): preserve called class in direct method calls * fix(compiler): resolve called-class function targets * fix(compiler): retain optimized namespace builtins --- phpunit/code/direct-get-called-class.php | 23 ++ .../get-called-class-namespace-fallback.php | 26 +++ .../code/get-called-class-shadow-alias.php | 20 ++ .../namespaced-builtin-fallback-optimizer.php | 42 ++++ .../code/namespaced-restricted-functions.php | 13 ++ .../namespaced-wasi-restricted-function.php | 8 + ...tive-class-get-called-class-namespaced.php | 12 ++ ...ative-class-get-called-class-qualified.php | 10 + .../native-class-get-called-class-shadow.php | 17 ++ .../code/native-class-get-called-class.php | 8 +- .../native-class-get-class-namespaced.php | 12 ++ .../code/native-class-get-class-shadow.php | 22 ++ ...tive-class-get-parent-class-namespaced.php | 15 ++ phpunit/code/qualified-runtime-function.php | 15 ++ .../code/random-invalid-random-int-arity.php | 2 + phpunit/code/scoped-call-context-reuse.php | 2 + phpunit/src/DirectGetCalledClassTest.php | 94 ++++++++ phpunit/src/FuncCallOptimizerTest.php | 20 ++ phpunit/src/NanoCapabilityPolicyTest.php | 30 +++ .../NativeClass/NativeClassValidationTest.php | 40 ++++ src/Build/NanoExtensionSelector.php | 12 +- src/CompilerBase.php | 25 ++- src/Parser/FunctionCallTrait.php | 204 +++++++++++++----- src/Resolver/DeclarationSymbolTrait.php | 2 +- src/Resolver/NameResolutionTrait.php | 5 +- src/Translator.php | 8 + .../class/direct-get-called-class.phpt | 84 ++++++++ ...namespaced-builtin-fallback-optimizer.phpt | 45 ++++ 28 files changed, 751 insertions(+), 65 deletions(-) create mode 100644 phpunit/code/direct-get-called-class.php create mode 100644 phpunit/code/get-called-class-namespace-fallback.php create mode 100644 phpunit/code/get-called-class-shadow-alias.php create mode 100644 phpunit/code/namespaced-builtin-fallback-optimizer.php create mode 100644 phpunit/code/namespaced-restricted-functions.php create mode 100644 phpunit/code/namespaced-wasi-restricted-function.php create mode 100644 phpunit/code/native-class-get-called-class-namespaced.php create mode 100644 phpunit/code/native-class-get-called-class-qualified.php create mode 100644 phpunit/code/native-class-get-called-class-shadow.php create mode 100644 phpunit/code/native-class-get-class-namespaced.php create mode 100644 phpunit/code/native-class-get-class-shadow.php create mode 100644 phpunit/code/native-class-get-parent-class-namespaced.php create mode 100644 phpunit/code/qualified-runtime-function.php create mode 100644 phpunit/src/DirectGetCalledClassTest.php create mode 100644 tests/compiler/class/direct-get-called-class.phpt create mode 100644 tests/compiler/function/namespaced-builtin-fallback-optimizer.phpt diff --git a/phpunit/code/direct-get-called-class.php b/phpunit/code/direct-get-called-class.php new file mode 100644 index 00000000..75893840 --- /dev/null +++ b/phpunit/code/direct-get-called-class.php @@ -0,0 +1,23 @@ +instanceName(); +} diff --git a/phpunit/code/get-called-class-namespace-fallback.php b/phpunit/code/get-called-class-namespace-fallback.php new file mode 100644 index 00000000..53073b78 --- /dev/null +++ b/phpunit/code/get-called-class-namespace-fallback.php @@ -0,0 +1,26 @@ +blocks = $blocks; + } + + public function countBlocks(): int + { + $this->size = count($this->blocks); + return $this->size; + } + + public function matches(): bool + { + return in_array('needle', $this->blocks, true) + && str_contains('needle in haystack', 'needle'); + } +} + +function strlen(string $value): int +{ + return 7; +} + +function shadowedLength(): int +{ + return strlen('ignored'); +} diff --git a/phpunit/code/namespaced-restricted-functions.php b/phpunit/code/namespaced-restricted-functions.php new file mode 100644 index 00000000..a20e63aa --- /dev/null +++ b/phpunit/code/namespaced-restricted-functions.php @@ -0,0 +1,13 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertSame(2, substr_count($code, 'const php::Str _typephp_called_class =')); + self::assertStringNotContainsString('php::call(', $code); + self::assertStringContainsString('php_directcalledbase__instancename(object)', $code); + } + + public function testCaseInsensitiveUserFunctionAliasIsNotReplacedWithClassName(): void + { + global $translator; + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/get-called-class-shadow-alias.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertStringNotContainsString('_typephp_called_class', $code); + self::assertStringContainsString('php_externalfunctions__shadow(', $code); + } + + public function testNamespacedFunctionShadowsAndFallsBackAtRuntime(): void + { + global $translator; + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $compiler->setBuildMode(CompilerBase::BUILD_MODE_EXT); + $compiler->setTargetName('get_called_class_fallback'); + (new \ReflectionProperty($compiler, 'noLiteralStrings'))->setValue($compiler, true); + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/get-called-class-namespace-fallback.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertStringContainsString('php_calledclassknown__get_called_class(', $code); + self::assertStringContainsString('php::fn::function_exists(', $code); + self::assertStringContainsString('typephp_call_cached(', $code); + self::assertStringContainsString('_typephp_called_class', $code); + self::assertStringContainsString('CalledClassDynamic\\\\get_called_class', $code); + self::assertStringNotContainsString('ZEND_STRL("get_called_class")', $code); + self::assertSame(1, substr_count($code, 'typephp_get_function_resolution_cache(')); + self::assertSame(1, substr_count($code, 'typephp_get_function_call_cache(')); + self::assertStringContainsString('resolution == 0', $code); + self::assertStringContainsString('resolution = php::fn::function_exists(', $code); + self::assertStringContainsString(') ? 1 : 2;', $code); + self::assertStringContainsString('resolution == 1', $code); + + $extension = file_get_contents($compiler->genExtension()); + self::assertIsString($extension); + self::assertStringContainsString('uint8_t function_resolution_cache_map[1]', $extension); + self::assertStringContainsString( + 'uint8_t &typephp_get_function_resolution_cache(FunctionResolutionCacheId cache_id)', + $extension, + ); + + $statistics = $compiler->getCompilationStatistics(); + self::assertTrue($statistics->has(CompilationStatistics::DIRECT_FUNCTIONS, 'function_exists')); + $selection = (new NanoExtensionSelector())->select($statistics, ['standard']); + self::assertSame(['standard.core'], $selection->features); + } + + public function testQualifiedRuntimeFunctionUsesResolvedTarget(): void + { + global $translator; + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + (new \ReflectionProperty($compiler, 'noLiteralStrings'))->setValue($compiler, true); + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/qualified-runtime-function.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertStringContainsString('Vendor\\\\Package\\\\runtime_function', $code); + self::assertStringContainsString('Consumer\\\\Sub\\\\runtime_function', $code); + self::assertStringNotContainsString('Lib\\\\runtime_function', $code); + } +} diff --git a/phpunit/src/FuncCallOptimizerTest.php b/phpunit/src/FuncCallOptimizerTest.php index 6002f225..556c5ad4 100644 --- a/phpunit/src/FuncCallOptimizerTest.php +++ b/phpunit/src/FuncCallOptimizerTest.php @@ -36,4 +36,24 @@ final class FuncCallOptimizerTest extends BaseTest self::assertStringContainsString('php::fn::floor(1.5)', $code); self::assertStringContainsString('php::fn::round(1.25)', $code); } + + public function testNamespacedBuiltinFallbackUsesDirectOptimizerPaths(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/namespaced-builtin-fallback-optimizer.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + self::assertSame(1, substr_count($code, 'php::fn::count(')); + self::assertSame(1, substr_count($code, 'php::fn::in_array(')); + self::assertSame(1, substr_count($code, 'php::fn::str_contains(')); + self::assertStringContainsString('php_namespacedbuiltinfallback__strlen(', $code); + self::assertStringNotContainsString('php::call(', $code); + } } diff --git a/phpunit/src/NanoCapabilityPolicyTest.php b/phpunit/src/NanoCapabilityPolicyTest.php index 0091f129..3659c5d2 100644 --- a/phpunit/src/NanoCapabilityPolicyTest.php +++ b/phpunit/src/NanoCapabilityPolicyTest.php @@ -200,6 +200,36 @@ final class NanoCapabilityPolicyTest extends BaseTest $compiler->convertFile($source); } + public function testNanoRejectsNamespacedRestrictedBuiltinFallback(): void + { + global $translator; + $compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); + $compiler->enableNanoForTest(); + $translator = $compiler; + $source = __DIR__ . '/../code/namespaced-restricted-functions.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Function `shell_exec` is not supported in nano mode'); + $compiler->convertFile($source); + } + + public function testWasiRejectsNamespacedRestrictedBuiltinFallback(): void + { + global $translator; + $compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); + $compiler->enableWasiForTest(); + $translator = $compiler; + $source = __DIR__ . '/../code/namespaced-wasi-restricted-function.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Function `stream_socket_client` is not supported by the WASI target'); + $compiler->convertFile($source); + } + public function testNanoKeepsTypedFileStreamMethods(): void { global $translator; diff --git a/phpunit/src/NativeClass/NativeClassValidationTest.php b/phpunit/src/NativeClass/NativeClassValidationTest.php index f34cf599..88fccfbe 100644 --- a/phpunit/src/NativeClass/NativeClassValidationTest.php +++ b/phpunit/src/NativeClass/NativeClassValidationTest.php @@ -929,6 +929,26 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-get-called-class.php'); } + public function testRejectsQualifiedGetCalledClassInNativeClass(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native classes do not support late static binding'); + $this->compile('native-class-get-called-class-qualified.php'); + } + + public function testRejectsNamespacedGetCalledClassInNativeClass(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native classes do not support late static binding'); + $this->compile('native-class-get-called-class-namespaced.php'); + } + + public function testAllowsCompiledNamespacedGetCalledClassShadowInNativeClass(): void + { + $this->compile('native-class-get-called-class-shadow.php'); + $this->addToAssertionCount(1); + } + public function testRejectsGetClassForNativeObject(): void { $this->expectException(TestError::class); @@ -936,6 +956,13 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-get-class.php'); } + public function testRejectsNamespacedGetClassForNativeObject(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native classes do not support runtime class introspection'); + $this->compile('native-class-get-class-namespaced.php'); + } + public function testRejectsImplicitGetClassInNativeMethod(): void { $this->expectException(TestError::class); @@ -950,6 +977,19 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-get-parent-class.php'); } + public function testRejectsNamespacedGetParentClassForNativeObject(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native classes do not support runtime class introspection'); + $this->compile('native-class-get-parent-class-namespaced.php'); + } + + public function testAllowsCompiledNamespacedGetClassShadowsInNativeClass(): void + { + $this->compile('native-class-get-class-shadow.php'); + $this->addToAssertionCount(1); + } + public function testRejectsChangingAnInferredNativeGlobalType(): void { $this->expectException(TestError::class); diff --git a/src/Build/NanoExtensionSelector.php b/src/Build/NanoExtensionSelector.php index 0f061184..3664ec56 100644 --- a/src/Build/NanoExtensionSelector.php +++ b/src/Build/NanoExtensionSelector.php @@ -284,6 +284,11 @@ final class NanoExtensionSelector } } foreach (array_keys($statistics->get(CompilationStatistics::DIRECT_FUNCTIONS)) as $function) { + $feature = $this->standardFeature($function); + if ($feature !== null) { + $features[$feature] = true; + continue; + } $owner = $this->functionExtension($function); if ($owner === 'date') { // Common date helpers are coupled to timelib and the DateTime @@ -292,12 +297,7 @@ final class NanoExtensionSelector continue; } if ($owner === 'standard') { - $feature = $this->standardFeature($function); - if ($feature === null) { - $selected['standard'] = true; - } else { - $features[$feature] = true; - } + $selected['standard'] = true; continue; } if ($owner !== null && isset($available[$owner])) { diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 89fe57a9..d263090a 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -441,6 +441,7 @@ class CompilerBase implements PropertyAccessContext protected int $propertyAccessCacheIndex = 0; protected int $methodCallCacheIndex = 0; protected int $functionCallCacheIndex = 0; + protected int $functionResolutionCacheIndex = 0; /** @var array> Prepared declaration ASTs keyed by real path. */ protected array $preparedFileAsts = []; protected bool $traitDeclarationsComposed = false; @@ -1557,6 +1558,14 @@ class CompilerBase implements PropertyAccessContext return 'typephp_get_function_call_cache(FunctionCallCacheId{' . $id . '})'; } + /** Reserve a request-local namespace-function resolution slot per call site. */ + protected function getFunctionResolutionCache(): string + { + $this->assertCompilerPhase(self::PHASE_CONVERT, 'function resolution cache ID allocation'); + $id = $this->functionResolutionCacheIndex++; + return 'typephp_get_function_resolution_cache(FunctionResolutionCacheId{' . $id . '})'; + } + /** Return the function-local late-static-bound class entry. */ protected function getCalledCeExpr(): string { @@ -3653,12 +3662,16 @@ class CompilerBase implements PropertyAccessContext $this->escapeName($this->getNamespacedClassName($funcName)), ]; } else { - $possibleFunctionNames = [$this->escapeName($funcName)]; - if ($this->namespace) { - $possibleFunctionNames[] = $this->escapeNamespace($this->namespace) . self::NAMESPACE_SEPARATOR . $this->escapeName($funcName); - } - if (isset($this->useFunctions[$funcName])) { - $possibleFunctionNames[] = $this->escapeNamespace($this->useFunctions[$funcName]); + $import = strtolower($funcName); + if (isset($this->useFunctions[$import])) { + $possibleFunctionNames = [$this->escapeNamespace($this->useFunctions[$import])]; + } else { + $possibleFunctionNames = []; + if ($this->namespace) { + $possibleFunctionNames[] = $this->escapeNamespace($this->namespace) + . self::NAMESPACE_SEPARATOR . $this->escapeName($funcName); + } + $possibleFunctionNames[] = $this->escapeName($funcName); } } diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index a1de091c..fc20b09f 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -20,6 +20,66 @@ use TypePhp\Exception\PlaceHolder; trait FunctionCallTrait { + /** + * Resolve the one static function name used by every call path. Function + * imports and function names are case-insensitive, unlike constant names. + * + * @return array{source: string, target: string, nativeLookup: string, lower: string, definitelyGlobal: bool, namespacedFallback: bool} + */ + protected function resolveStaticFunctionCallTarget(Node\Name $name): array + { + $source = $this->parseIdentifier($name); + $bare = ltrim($source, '\\'); + $unqualified = !str_contains($bare, '\\'); + $fullyQualified = $name instanceof Node\Name\FullyQualified; + $import = $unqualified && !$fullyQualified ? strtolower($bare) : ''; + $imported = $import !== '' && isset($this->useFunctions[$import]); + $resolved = $name->getAttribute('resolvedName'); + $target = $resolved instanceof Node\Name + ? ltrim($resolved->toString(), '\\') + : ($imported ? $this->useFunctions[$import] : $bare); + $lower = strtolower($target); + $namespacedFallback = !$fullyQualified + && !$imported + && $unqualified + && $this->namespace !== ''; + + return [ + 'source' => $source, + 'target' => $target, + // The raw short name is the only form that retains PHP's + // namespace-to-global lookup fallback. All resolved/imported/ + // qualified names must not be interpreted through imports again. + 'nativeLookup' => !$fullyQualified && !$imported && $unqualified + ? $source + : '\\' . $target, + 'lower' => $lower, + // A namespaced short name can be shadowed at runtime, so only + // these forms are known to name a global builtin directly. + 'definitelyGlobal' => $fullyQualified + || ($imported && !str_contains($target, '\\')) + || ($this->namespace === '' && $unqualified), + 'namespacedFallback' => $namespacedFallback, + ]; + } + + /** + * PHP resolves an unqualified namespaced function dynamically before + * falling back to its global builtin. Resolve on the first execution and + * cache that target choice for the rest of the request. + */ + protected function parseNamespacedGetCalledClassFallback(string $function): string + { + $function = $this->getLiteralString($function); + $resolution = $this->getFunctionResolutionCache(); + $this->compilationStatistics->record(CompilationStatistics::DIRECT_FUNCTIONS, 'function_exists'); + return '([&]() -> php::Var { auto &resolution = ' . $resolution + . '; if (resolution == 0) { resolution = php::fn::function_exists(' . $function + . ') ? 1 : 2; } return resolution == 1 ? typephp_call_cached(' + . $function . ', ' . $this->getFunctionCallCache() . ') : php::Var(' + . $this->getCalledClassExpr() . '); })()'; + } + protected function parsePipeOperator(Expr\BinaryOp\Pipe $expr): string { $this->assertExprCanBeUsedAsValue($expr->left, 'pipe left operand'); @@ -112,18 +172,25 @@ trait FunctionCallTrait $fn = $this->parseIdentifier($expr->name); $placeHolder = $fn; $name = ''; - } elseif ($expr->name->getType() === 'Name' or $expr->name->getType() === 'Name_FullyQualified') { - $name = $this->parseIdentifier($expr->name); - $globalName = ltrim($name, '\\'); + } elseif ($expr->name instanceof Node\Name) { + $functionTarget = $this->resolveStaticFunctionCallTarget($expr->name); + $name = $functionTarget['target']; + $globalName = $functionTarget['lower']; $this->compilationStatistics->record( CompilationStatistics::FUNCTIONS, - strtolower($globalName), + $globalName, ); - $namedExit = $this->parseNamedExitMessageCall($globalName, $expr); + $namedExit = $functionTarget['definitelyGlobal'] + ? $this->parseNamedExitMessageCall($globalName, $expr) + : null; if ($namedExit !== null) { return $namedExit; } - if ($globalName === 'clone' && !$expr->isFirstClassCallable() && $this->class) { + if ($functionTarget['definitelyGlobal'] + && $globalName === 'clone' + && !$expr->isFirstClassCallable() + && $this->class + ) { // PHP 8.5 clone-with applies property updates in the lexical // scope of the call site. Direct AOT method calls do not leave // a Zend execute frame on top, so preserve that scope while @@ -132,53 +199,23 @@ trait FunctionCallTrait ? 'php::FakeScopeGuard::current()' : $this->getClassEntryPtr($this->getFullClassName()); } - if ($globalName === 'get_called_class' && $this->classDef?->nativeObject) { - $this->fatalError( - $expr, - 'Native classes do not support late static binding; use `self::class` or a concrete class name', - ); - } - if (($globalName === 'get_class' || $globalName === 'get_parent_class') - && (($expr->args === [] && $this->classDef?->nativeObject) - || ($expr->args !== [] - && $this->isNativeObjectClass($this->detectClassOfExpr($expr->args[0]->value)))) - ) { - $replacement = $globalName === 'get_class' - ? '`self::class` or a concrete class name' - : '`parent::class` or a concrete class name'; - $this->fatalError( - $expr, - "Native classes do not support runtime class introspection; use {$replacement}", - ); - } - // Capability policy is determined by the target, not by the - // extensions loaded into the build-time PHP process. Otherwise a - // forbidden direct call could bypass validation merely because - // that host PHP does not expose the function. - $this->assertWasiFunctionSupported($expr, $globalName); - $this->assertNanoFunctionSupported($expr, $globalName); - if ($this->isInternalFunction($globalName)) { - $this->markInternalFunctionCallbackCall($globalName, $expr->args); - } - if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) { - $this->fatalError($expr, 'Unsupported function: `' . $globalName . '`'); - } - $nativeFn = $this->findNativeFunction($name); + $nativeFn = $this->findNativeFunction($functionTarget['nativeLookup']); if ($nativeFn) { + $functionDef = $this->getFunction($nativeFn); + $resolvedTarget = $functionDef->getNamespacedName(); $expr->setAttribute('nativeCall', $nativeFn); if ($expr->isFirstClassCallable() - && $this->functionUsesNativeObject($this->getFunction($nativeFn)) + && $this->functionUsesNativeObject($functionDef) ) { $this->fatalError($expr, 'Native ABI functions cannot be converted to Zend closures'); } // Function call placeholder, not a real function call if (count($expr->args) === 1 and $this->isPlaceholderExpr($expr->args[0])) { - return $this->genPlaceHolder($this->identifierToStr($expr->name)); + return $this->genPlaceHolder($this->getLiteralString($resolvedTarget)); } - $this->checkNativeCallArgs($expr, $this->getFunction($nativeFn), $expr->args, $name); + $this->checkNativeCallArgs($expr, $functionDef, $expr->args, $resolvedTarget); if ($this->shouldUseDynamicCallForNativeArgs($nativeFn, $expr->args)) { - $functionDef = $this->getFunction($nativeFn); - return $this->genRuntimeFunctionCall($this->getFuncPtr($functionDef->getNamespacedName()), $expr->args, $name); + return $this->genRuntimeFunctionCall($this->getFuncPtr($resolvedTarget), $expr->args, $resolvedTarget); } try { $callee = $expr->getAttribute(self::ATTR_MULTI_RETURN_IMPL, false) @@ -186,13 +223,84 @@ trait FunctionCallTrait : self::PREFIX . $nativeFn; return $callee . '(' . $this->parseNativeCallArgs($expr->args, $nativeFn) . ')'; } catch (PlaceHolder) { - return $this->genPlaceHolder($this->identifierToStr($expr->name)); + return $this->genPlaceHolder($this->getLiteralString($resolvedTarget)); + } + } + $mayCallGlobalBuiltin = $functionTarget['definitelyGlobal'] || $functionTarget['namespacedFallback']; + $isNamespacedGetCalledClassFallback = $functionTarget['namespacedFallback'] + && $globalName === 'get_called_class' + && $expr->args === [] + && $this->methodDef !== null + && !$this->classDef?->nativeObject; + // Policy applies only after compiled namespace shadows/imported + // user functions have had a chance to resolve. A missing + // namespaced short name can still fall back to a global builtin. + if ($mayCallGlobalBuiltin) { + $this->assertWasiFunctionSupported($expr, $globalName); + $this->assertNanoFunctionSupported($expr, $globalName); + if (!$isNamespacedGetCalledClassFallback && $this->isInternalFunction($globalName)) { + $this->markInternalFunctionCallbackCall($globalName, $expr->args); } + if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) { + $this->fatalError($expr, 'Unsupported function: `' . $globalName . '`'); + } + } + if ($mayCallGlobalBuiltin + && ($globalName === 'get_class' || $globalName === 'get_parent_class') + && (($expr->args === [] && $this->classDef?->nativeObject) + || ($expr->args !== [] + && $this->isNativeObjectClass($this->detectClassOfExpr($expr->args[0]->value)))) + ) { + $replacement = $globalName === 'get_class' + ? '`self::class` or a concrete class name' + : '`parent::class` or a concrete class name'; + $this->fatalError( + $expr, + "Native classes do not support runtime class introspection; use {$replacement}", + ); + } + if ($mayCallGlobalBuiltin + && $globalName === 'get_called_class' + && $this->classDef?->nativeObject + ) { + $this->fatalError( + $expr, + 'Native classes do not support late static binding; use `self::class` or a concrete class name', + ); } // For dynamically dispatched functions, convert the function name to its fully qualified name including the namespace - $name = $this->getNamespacedFuncName($name); - $this->checkInternalFunctionArgCount($name, $expr); - $code = $this->parseFuncCallWithOptimizer($name, $expr); + $name = $functionTarget['target']; + if ($functionTarget['definitelyGlobal']) { + $name = strtolower($name); + } + if ($mayCallGlobalBuiltin && !$isNamespacedGetCalledClassFallback) { + $this->checkInternalFunctionArgCount($name, $expr); + } + if ($functionTarget['definitelyGlobal'] + && $globalName === 'get_called_class' + && $expr->args === [] + && $this->methodDef !== null + && !$this->classDef?->nativeObject + ) { + // Direct AOT calls have no Zend method frame for the builtin + // to inspect. Reuse the runtime scope used by static::class. + return $this->getCalledClassExpr(); + } + if ($functionTarget['namespacedFallback'] + && $globalName === 'get_called_class' + && $expr->args === [] + && $this->methodDef !== null + && !$this->classDef?->nativeObject + ) { + return $this->parseNamespacedGetCalledClassFallback( + $this->namespace . '\\' . ltrim($functionTarget['source'], '\\'), + ); + } + $canOptimizeBuiltinFallback = $functionTarget['definitelyGlobal'] + || ($functionTarget['namespacedFallback'] && $globalName !== 'get_called_class'); + $code = $canOptimizeBuiltinFallback + ? $this->parseFuncCallWithOptimizer($globalName, $expr) + : false; if ($code !== false) { // Constant folding and native container operations do not // retain the PHP function. Record only emitted stdlib calls. @@ -208,7 +316,7 @@ trait FunctionCallTrait CompilationStatistics::RUNTIME_FUNCTIONS, strtolower($globalName), ); - $placeHolder = $this->identifierToStr($expr->name); + $placeHolder = $this->getLiteralString($functionTarget['target']); $fn = $this->getFuncPtr($name); if ($this->debug) { $this->context->beforeStmtLines[] = $this->formatCppLineComment('Func Call: ', $name . '()'); diff --git a/src/Resolver/DeclarationSymbolTrait.php b/src/Resolver/DeclarationSymbolTrait.php index c2987cf0..967a579b 100644 --- a/src/Resolver/DeclarationSymbolTrait.php +++ b/src/Resolver/DeclarationSymbolTrait.php @@ -78,7 +78,7 @@ trait DeclarationSymbolTrait $type = $use->type !== Node\Stmt\Use_::TYPE_UNKNOWN ? $use->type : $v2->type; $alias = $this->registerUseImportAlias($use, $type, $id); if ($type === Node\Stmt\Use_::TYPE_FUNCTION) { - $this->useFunctions[$alias] = $id; + $this->useFunctions[strtolower($alias)] = $id; } elseif ($type === Node\Stmt\Use_::TYPE_CONSTANT) { // $id is already the fully qualified constant name. Splitting // and re-joining it on `\` corrupted single-segment imports: diff --git a/src/Resolver/NameResolutionTrait.php b/src/Resolver/NameResolutionTrait.php index 699307e7..485b99cb 100644 --- a/src/Resolver/NameResolutionTrait.php +++ b/src/Resolver/NameResolutionTrait.php @@ -146,8 +146,9 @@ trait NameResolutionTrait if ($funcName[0] == '\\') { return ltrim($funcName, '\\'); } - if (isset($this->useFunctions[$funcName])) { - return $this->useFunctions[$funcName]; + $import = strtolower($funcName); + if (isset($this->useFunctions[$import])) { + return $this->useFunctions[$import]; } return $funcName; } diff --git a/src/Translator.php b/src/Translator.php index 8cde408e..85bd7332 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -998,6 +998,7 @@ class Translator extends Preprocessor $lines[] = 'enum class PropertyCacheId : uint32_t {};' . PHP_EOL; $lines[] = 'enum class MethodCallCacheId : uint32_t {};' . PHP_EOL; $lines[] = 'enum class FunctionCallCacheId : uint32_t {};' . PHP_EOL; + $lines[] = 'enum class FunctionResolutionCacheId : uint32_t {};' . PHP_EOL; $lines[] = 'zend_class_entry *get_class(RequestClassId class_id, const php::Str &class_name);'; $lines[] = 'zend_function *get_func(RequestFuncId func_id, const php::Str &func_name);'; @@ -1009,6 +1010,7 @@ class Translator extends Preprocessor $lines[] = 'php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id) noexcept;' . PHP_EOL; $lines[] = 'php::MethodCallCacheSlot &typephp_get_method_call_cache(MethodCallCacheId cache_id) noexcept;' . PHP_EOL; $lines[] = 'php::FunctionCallCacheSlot &typephp_get_function_call_cache(FunctionCallCacheId cache_id) noexcept;' . PHP_EOL; + $lines[] = 'uint8_t &typephp_get_function_resolution_cache(FunctionResolutionCacheId cache_id) noexcept;' . PHP_EOL; foreach ($this->getClassLikesWithConstants() as $classDef) { foreach ($classDef->constants as $constant) { @@ -1199,6 +1201,8 @@ class Translator extends Preprocessor . max(1, $this->methodCallCacheIndex) . ']{};' . PHP_EOL; $code .= $this->getIndent() . 'php::FunctionCallCacheSlot function_call_cache_map[' . max(1, $this->functionCallCacheIndex) . ']{};' . PHP_EOL; + $code .= $this->getIndent() . 'uint8_t function_resolution_cache_map[' + . max(1, $this->functionResolutionCacheIndex) . ']{};' . PHP_EOL; $code .= '};' . PHP_EOL; $code .= 'static THREAD_LOCAL php_request_cache_storage *php_request_cache = nullptr;' . PHP_EOL; @@ -1289,6 +1293,10 @@ php::MethodCallCacheSlot &typephp_get_method_call_cache(MethodCallCacheId cache_ php::FunctionCallCacheSlot &typephp_get_function_call_cache(FunctionCallCacheId cache_id) noexcept { return php_request_cache->function_call_cache_map[static_cast(cache_id)]; } + +uint8_t &typephp_get_function_resolution_cache(FunctionResolutionCacheId cache_id) noexcept { + return php_request_cache->function_resolution_cache_map[static_cast(cache_id)]; +} CODE; $code .= "\n\n"; diff --git a/tests/compiler/class/direct-get-called-class.phpt b/tests/compiler/class/direct-get-called-class.phpt new file mode 100644 index 00000000..4c06744c --- /dev/null +++ b/tests/compiler/class/direct-get-called-class.phpt @@ -0,0 +1,84 @@ +--TEST-- +get_called_class preserves runtime scope across direct instance and static calls +--FILE-- +name(), ':', $object->traitName(), ':', + $object->aliasName(), ':', $object->closureName(), "\n"; + } + function main(): void { + printCalledNames(new CalledScopeBase()); + printCalledNames(new CalledScopeChild()); + echo CalledScopeBase::staticName(), ':', CalledScopeChild::staticName(), "\n"; + eval('class RuntimeCalledScope extends CalledScopeBase {}'); + $runtime = eval('return new RuntimeCalledScope();'); + printCalledNames($runtime); + echo (new \CalledShadow\Probe())->name(), "\n"; + echo (new \CalledAlias\Probe())->name(), "\n"; + eval('namespace CalledDynamicShadow { function get_called_class(): string { return "shadow-first"; } }'); + echo (new \CalledDynamicShadow\Probe())->name(), "\n"; + echo (new \CalledDynamicFrozen\Probe())->name(), "\n"; + eval('namespace CalledDynamicFrozen { function get_called_class(): string { return "shadow-late"; } }'); + echo (new \CalledDynamicFrozen\Child())->name(), "\n"; + try { + get_called_class(); + } catch (\Error $error) { + echo $error->getMessage(), "\n"; + } + } +} +?> +--EXPECT-- +CalledScopeBase:CalledScopeBase:CalledScopeBase:CalledScopeBase +CalledScopeChild:CalledScopeChild:CalledScopeChild:CalledScopeChild +CalledScopeBase:CalledScopeChild +RuntimeCalledScope:RuntimeCalledScope:RuntimeCalledScope:RuntimeCalledScope +shadow +alias +shadow-first +CalledDynamicFrozen\Probe +CalledDynamicFrozen\Child +get_called_class() must be called from within a class diff --git a/tests/compiler/function/namespaced-builtin-fallback-optimizer.phpt b/tests/compiler/function/namespaced-builtin-fallback-optimizer.phpt new file mode 100644 index 00000000..b5e69ac2 --- /dev/null +++ b/tests/compiler/function/namespaced-builtin-fallback-optimizer.phpt @@ -0,0 +1,45 @@ +--TEST-- +namespaced builtin fallbacks retain direct optimizer paths while compiled shadows win +--FILE-- +blocks = $blocks; + } + + public function countBlocks(): int { + $this->size = count($this->blocks); + return $this->size; + } + + public function matches(): bool { + return in_array('needle', $this->blocks, true) + && str_contains('needle in haystack', 'needle'); + } + } + + function strlen(string $value): int { + return 7; + } + + function shadowedLength(): int { + return strlen('ignored'); + } +} +namespace { + function main(): void { + $probe = new \NamespacedBuiltinFallback\Probe(['needle']); + echo $probe->countBlocks(), "\n"; + var_dump($probe->matches()); + echo \NamespacedBuiltinFallback\shadowedLength(), "\n"; + } +} +?> +--EXPECT-- +1 +bool(true) +7