diff --git a/phpunit/code/native-class-dynamic-class-expression.php b/phpunit/code/native-class-dynamic-class-expression.php new file mode 100644 index 00000000..4c03298f --- /dev/null +++ b/phpunit/code/native-class-dynamic-class-expression.php @@ -0,0 +1,11 @@ +value; +} diff --git a/phpunit/code/native-class-global-forward/b.php b/phpunit/code/native-class-global-forward/b.php new file mode 100644 index 00000000..e901b955 --- /dev/null +++ b/phpunit/code/native-class-global-forward/b.php @@ -0,0 +1,22 @@ +addToAssertionCount(1); } + public function testDiscoversNativeGlobalSlotBeforeEarlierReaderIsConverted(): void + { + global $translator; + + $compiler = \TypePhp\CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $directory = dirname(__DIR__, 2) . '/code/native-class-global-forward'; + $files = [$directory . '/a.php', $directory . '/b.php']; + $compiler->discoverNativeClassDeclarations($files); + foreach ($files as $file) { + $compiler->prepareFile($file); + } + $compiler->discoverNativeGlobalObjects($files); + $reader = $compiler->convertFile($files[0]); + $compiler->convertFile($files[1]); + + $code = file_get_contents($reader); + self::assertIsString($code); + self::assertStringContainsString( + 'php::nativeDeref(nativeForwardGlobal, "NativeForwardGlobalValue").value', + $code, + ); + self::assertStringNotContainsString('nativeForwardGlobal.attr(', $code); + } + public function testRejectsNativeAttributeOnInterface(): void { $this->expectException(\TypePhp\Exception\SyntaxError::class); @@ -241,6 +266,11 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-dynamic-new.php'); } + public function testLeavesDynamicClassExpressionsToTheOrdinaryPhpPath(): void + { + $this->compile('native-class-dynamic-class-expression.php'); + } + public function testRejectsNativeObjectAsDynamicStaticCallTarget(): void { $this->expectException(TestError::class); diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 1e7d19f3..d8268f8f 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -150,6 +150,10 @@ trait SourcePipelineTrait } } } + // Global slots are shared by every translation unit. Fix any Native + // pointer ABI now, after declarations are known and before the first + // per-file C++ body is generated. + $this->discoverNativeGlobalObjects(array_values($files)); $files = $this->getSortedFiles($files); return $files; } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index afd92111..3ea4691e 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3780,6 +3780,10 @@ class CompilerBase implements PropertyAccessContext if ($nativeCtor === false) { $this->fatalError($expr, "Native constructor `{$className}::__construct()` cannot be resolved"); } + // Keep the generated C++ argument text separate from + // the AST argument array used by the ordinary-class + // path below. The self-hosted compiler assigns one + // fixed C++ type to each PHP local variable. $nativeArgs = $expr->args === [] ? '' : ', ' . $this->parseNativeCallArgs($expr->args, $nativeCtor); diff --git a/src/Context/CompilationStateTrait.php b/src/Context/CompilationStateTrait.php index 7d7bd719..144316d6 100644 --- a/src/Context/CompilationStateTrait.php +++ b/src/Context/CompilationStateTrait.php @@ -91,6 +91,22 @@ trait CompilationStateTrait } else { return; } + $class = $this->registerNativeGlobalObject($slot, $class, $node); + $this->addNativeObject($name, $class); + } + + /** + * Fix the C++ pointer ABI of a project-wide global/static slot. + * + * The project discovery pass calls this before C++ emission; the ordinary + * convert path calls it again to validate every concrete assignment. + */ + protected function registerNativeGlobalObject( + string $slot, + string $class, + ?NodeAbstract $node = null, + ): string { + $class = ltrim($class, '\\'); if (isset($this->nativeGlobalObjects[$slot])) { $existing = $this->nativeGlobalObjects[$slot]; if (!$this->isObjectClassStaticallyAssignableTo($class, $existing)) { @@ -106,7 +122,7 @@ trait CompilationStateTrait } $this->globalVars[$slot] = $this->getNativeObjectPointerType($class); $this->nativeGlobalObjects[$slot] = $class; - $this->addNativeObject($name, $class); + return $class; } protected function addScopeGlobalVar(string $name, string $type): void diff --git a/src/NativeClass/NativeGlobalDiscovery.php b/src/NativeClass/NativeGlobalDiscovery.php new file mode 100644 index 00000000..10624e80 --- /dev/null +++ b/src/NativeClass/NativeGlobalDiscovery.php @@ -0,0 +1,392 @@ +): ?string */ + private \Closure $functionReturn; + + /** @var \Closure(string, string): ?string */ + private \Closure $methodReturn; + + /** @var \Closure(string, string): ?string */ + private \Closure $propertyClass; + + /** @var \Closure(string, string): ?string */ + private \Closure $commonClass; + + /** @var \Closure(string): ?string */ + private \Closure $parentClass; + + private string $scopeClass = ''; + + /** + * @param \Closure(string): ?string $canonicalClass + * @param \Closure(string): ?string $nativeClass + * @param \Closure(list): ?string $functionReturn + * @param \Closure(string, string): ?string $methodReturn + * @param \Closure(string, string): ?string $propertyClass + * @param \Closure(string, string): ?string $commonClass + * @param \Closure(string): ?string $parentClass + */ + public function __construct( + \Closure $canonicalClass, + \Closure $nativeClass, + \Closure $functionReturn, + \Closure $methodReturn, + \Closure $propertyClass, + \Closure $commonClass, + \Closure $parentClass, + ) { + $this->canonicalClass = $canonicalClass; + $this->nativeClass = $nativeClass; + $this->functionReturn = $functionReturn; + $this->methodReturn = $methodReturn; + $this->propertyClass = $propertyClass; + $this->commonClass = $commonClass; + $this->parentClass = $parentClass; + } + + /** + * @param list $statements + * @return list + */ + public function discover(array $statements): array + { + $result = []; + $this->discoverStatements($statements, '', $result); + return $result; + } + + /** + * @param list $statements + * @param list $result + */ + private function discoverStatements(array $statements, string $class, array &$result): void + { + foreach ($statements as $statement) { + if ($statement instanceof Node\Stmt\Namespace_) { + $this->discoverStatements($statement->stmts, '', $result); + continue; + } + if ($statement instanceof Node\Stmt\Function_) { + $this->discoverFunction($statement, '', $result); + continue; + } + if ($statement instanceof Node\Stmt\ClassLike) { + $className = isset($statement->namespacedName) + ? $statement->namespacedName->toString() + : ($statement->name?->toString() ?? ''); + foreach ($statement->getMethods() as $method) { + $this->discoverFunction($method, $className, $result); + } + } + } + } + + /** + * @param list $result + */ + private function discoverFunction( + Node\Stmt\Function_|Node\Stmt\ClassMethod $function, + string $class, + array &$result, + ): void { + if ($function->stmts === null) { + return; + } + + $globals = []; + $this->collectGlobals($function->stmts, $globals); + if ($globals === [] && !$this->containsGlobalsArray($function->stmts)) { + return; + } + + $locals = []; + $previousScope = $this->scopeClass; + $thisClass = ($this->canonicalClass)($class); + $this->scopeClass = $thisClass ?? ''; + if ($thisClass !== null) { + $locals['this'] = $thisClass; + } + foreach ($function->params as $parameter) { + if (!$parameter->var instanceof Node\Expr\Variable || !is_string($parameter->var->name)) { + continue; + } + $parameterClass = $this->classFromType($parameter->type); + if ($parameterClass !== null) { + $locals[$parameter->var->name] = $parameterClass; + } + } + + $this->analyzeNodes($function->stmts, $globals, $locals, $result); + $this->scopeClass = $previousScope; + } + + /** @param array $globals */ + private function collectGlobals(mixed $node, array &$globals): void + { + if ($node === null) { + return; + } + if (is_array($node)) { + foreach ($node as $item) { + $this->collectGlobals($item, $globals); + } + return; + } + if (!$node instanceof NodeAbstract || $node instanceof Node\FunctionLike) { + return; + } + if ($node instanceof Node\Stmt\Global_) { + foreach ($node->vars as $variable) { + if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) { + $globals[$variable->name] = true; + } + } + return; + } + foreach ($node->getSubNodeNames() as $name) { + $this->collectGlobals($node->{$name}, $globals); + } + } + + private function containsGlobalsArray(mixed $node): bool + { + if ($node === null) { + return false; + } + if (is_array($node)) { + foreach ($node as $item) { + if ($this->containsGlobalsArray($item)) { + return true; + } + } + return false; + } + if (!$node instanceof NodeAbstract || $node instanceof Node\FunctionLike) { + return false; + } + if ($this->globalArrayName($node) !== null) { + return true; + } + foreach ($node->getSubNodeNames() as $name) { + if ($this->containsGlobalsArray($node->{$name})) { + return true; + } + } + return false; + } + + /** + * @param array $globals + * @param array $locals + * @param list $result + */ + private function analyzeNodes( + mixed $node, + array $globals, + array &$locals, + array &$result, + ): void { + if ($node === null) { + return; + } + if (is_array($node)) { + foreach ($node as $item) { + $this->analyzeNodes($item, $globals, $locals, $result); + } + return; + } + if (!$node instanceof NodeAbstract || $node instanceof Node\FunctionLike) { + return; + } + if ($node instanceof Node\Expr\Assign) { + $this->analyzeNodes($node->expr, $globals, $locals, $result); + $class = $this->inferClass($node->expr, $locals); + $globalName = $this->assignmentGlobalName($node->var, $globals); + $nativeClass = $class === null ? null : ($this->nativeClass)($class); + if ($globalName !== null && $nativeClass !== null) { + $result[] = ['name' => $globalName, 'class' => $nativeClass, 'node' => $node]; + } elseif ($node->var instanceof Node\Expr\Variable && is_string($node->var->name)) { + if ($class !== null) { + $locals[$node->var->name] = $class; + } elseif (!$this->isNull($node->expr)) { + unset($locals[$node->var->name]); + } + } + $this->analyzeNodes($node->var, $globals, $locals, $result); + return; + } + foreach ($node->getSubNodeNames() as $name) { + $this->analyzeNodes($node->{$name}, $globals, $locals, $result); + } + } + + /** @param array $locals */ + private function inferClass(NodeAbstract $expression, array $locals): ?string + { + if ($expression instanceof Node\Expr\Assign) { + return $this->inferClass($expression->expr, $locals); + } + if ($expression instanceof Node\Expr\New_ && $expression->class instanceof Node\Name) { + return $this->resolvedClass($expression->class); + } + if ($expression instanceof Node\Expr\Variable && is_string($expression->name)) { + return $locals[$expression->name] ?? null; + } + if ($expression instanceof Node\Expr\Clone_ + || $expression instanceof Node\Expr\ErrorSuppress + ) { + return $this->inferClass($expression->expr, $locals); + } + if ($expression instanceof Node\Expr\FuncCall && $expression->name instanceof Node\Name) { + return ($this->functionReturn)($this->resolvedNameCandidates($expression->name)); + } + if ($expression instanceof Node\Expr\StaticCall + && $expression->class instanceof Node\Name + && $expression->name instanceof Node\Identifier + ) { + $class = $this->resolvedClass($expression->class); + return $class === null ? null : ($this->methodReturn)($class, $expression->name->toString()); + } + if ($expression instanceof Node\Expr\MethodCall + && $expression->name instanceof Node\Identifier + ) { + $class = $this->inferClass($expression->var, $locals); + return $class === null ? null : ($this->methodReturn)($class, $expression->name->toString()); + } + if ($expression instanceof Node\Expr\PropertyFetch + && $expression->name instanceof Node\Identifier + ) { + $class = $this->inferClass($expression->var, $locals); + return $class === null ? null : ($this->propertyClass)($class, $expression->name->toString()); + } + if ($expression instanceof Node\Expr\Ternary) { + $if = $expression->if === null + ? $this->inferClass($expression->cond, $locals) + : $this->inferClass($expression->if, $locals); + return $this->mergeClasses($if, $this->inferClass($expression->else, $locals)); + } + if ($expression instanceof Node\Expr\BinaryOp\Coalesce) { + return $this->mergeClasses( + $this->inferClass($expression->left, $locals), + $this->inferClass($expression->right, $locals), + ); + } + if ($expression instanceof Node\Expr\Match_) { + $class = null; + foreach ($expression->arms as $arm) { + $class = $this->mergeClasses($class, $this->inferClass($arm->body, $locals)); + } + return $class; + } + return null; + } + + private function mergeClasses(?string $left, ?string $right): ?string + { + if ($left === null) { + return $right; + } + if ($right === null) { + return $left; + } + return ($this->commonClass)($left, $right); + } + + private function classFromType(?NodeAbstract $type): ?string + { + if ($type instanceof Node\NullableType) { + $type = $type->type; + } + return $type instanceof Node\Name + ? $this->resolvedClass($type) + : null; + } + + /** @param array $globals */ + private function assignmentGlobalName(NodeAbstract $target, array $globals): ?string + { + if ($target instanceof Node\Expr\Variable + && is_string($target->name) + && isset($globals[$target->name]) + ) { + return $target->name; + } + return $this->globalArrayName($target); + } + + private function globalArrayName(NodeAbstract $node): ?string + { + if (!$node instanceof Node\Expr\ArrayDimFetch + || !$node->var instanceof Node\Expr\Variable + || $node->var->name !== 'GLOBALS' + || !$node->dim instanceof Node\Scalar\String_ + ) { + return null; + } + return $node->dim->value; + } + + private function isNull(NodeAbstract $expression): bool + { + return $expression instanceof Node\Expr\ConstFetch + && strtolower($expression->name->toString()) === 'null'; + } + + private function resolvedName(Node\Name $name): string + { + $resolved = $name->getAttribute('resolvedName'); + return ltrim($resolved instanceof Node\Name ? $resolved->toString() : $name->toString(), '\\'); + } + + private function resolvedClass(Node\Name $name): ?string + { + $keyword = strtolower($name->toString()); + if ($keyword === 'self' || $keyword === 'static') { + return $this->scopeClass !== '' ? $this->scopeClass : null; + } + if ($keyword === 'parent') { + return $this->scopeClass !== '' ? ($this->parentClass)($this->scopeClass) : null; + } + return ($this->canonicalClass)($this->resolvedName($name)); + } + + /** @return list */ + private function resolvedNameCandidates(Node\Name $name): array + { + $names = [$this->resolvedName($name)]; + $fallback = $name->getAttribute('fallbackName'); + if ($fallback instanceof Node\Name) { + $names[] = ltrim($fallback->toString(), '\\'); + } + $names[] = ltrim($name->toString(), '\\'); + return array_values(array_unique($names)); + } +} diff --git a/src/Preprocessor.php b/src/Preprocessor.php index c4654038..0aedde23 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -30,6 +30,7 @@ use TypePhp\Transform\FunctionAttributeLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\Visitor; +use TypePhp\NativeClass\NativeGlobalDiscovery; use PhpParser\Modifiers; use PhpParser\ConstExprEvaluator; use PhpParser\Node; @@ -106,6 +107,135 @@ class Preprocessor extends CompilerBase } } + /** + * Discover Native Object globals after all declarations have been prepared + * but before the first translation unit is emitted. + * + * @param list $files + */ + public function discoverNativeGlobalObjects(array $files): void + { + $hasNativeClass = false; + foreach ($this->symbols->classes() as $class) { + if ($class->nativeObject) { + $hasNativeClass = true; + break; + } + } + if (!$hasNativeClass) { + return; + } + + $functionReturns = []; + foreach ($this->symbols->functions() as $function) { + if (!$function->method && $this->hasClass($function->returnClass)) { + $functionReturns[strtolower(ltrim($function->getNamespacedName(), '\\'))] + = $this->getClass($function->returnClass)->getNamespacedName(false); + } + } + + $discovery = new NativeGlobalDiscovery( + function (string $class): ?string { + $class = ltrim($class, '\\'); + if (!$this->hasClass($class)) { + return null; + } + return $this->getClass($class)->getNamespacedName(false); + }, + function (string $class): ?string { + $class = ltrim($class, '\\'); + if (!$this->isNativeObjectClass($class)) { + return null; + } + return $this->getClass($class)->getNamespacedName(false); + }, + static function (array $names) use ($functionReturns): ?string { + foreach ($names as $name) { + $key = strtolower(ltrim($name, '\\')); + if (isset($functionReturns[$key])) { + return $functionReturns[$key]; + } + } + return null; + }, + function (string $class, string $method): ?string { + while ($this->hasClass($class)) { + $classDefinition = $this->getClass($class); + if ($classDefinition->hasMethod($method)) { + $returnClass = $classDefinition->getMethod($method)->functionDef->returnClass; + return $this->hasClass($returnClass) + ? $this->getClass($returnClass)->getNamespacedName(false) + : null; + } + $class = $classDefinition->extends; + } + return null; + }, + function (string $class, string $property): ?string { + while ($this->hasClass($class)) { + $classDefinition = $this->getClass($class); + if ($classDefinition->hasProperty($property)) { + $propertyClass = $classDefinition->getProperty($property)->class; + return $this->hasClass($propertyClass) + ? $this->getClass($propertyClass)->getNamespacedName(false) + : null; + } + $class = $classDefinition->extends; + } + return null; + }, + function (string $left, string $right): ?string { + if ($this->isObjectClassStaticallyAssignableTo($left, $right)) { + return $right; + } + if ($this->isObjectClassStaticallyAssignableTo($right, $left)) { + return $left; + } + return null; + }, + function (string $class): ?string { + if (!$this->hasClass($class)) { + return null; + } + $parent = $this->getClass($class)->extends; + return $this->hasClass($parent) + ? $this->getClass($parent)->getNamespacedName(false) + : null; + }, + ); + + foreach ($files as $file) { + if (!$this->isPhpFileForNativeDiscovery($file)) { + continue; + } + $source = file_get_contents($file); + if (!is_string($source) + || (!str_contains($source, 'global') && !str_contains($source, '$GLOBALS')) + ) { + continue; + } + try { + $ast = $this->parser->parse($source); + } catch (\PhpParser\Error) { + // prepareFile() has already emitted the authoritative syntax + // diagnostic. This pass must not report it a second time. + continue; + } + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + $ast = $traverser->traverse($ast); + foreach ($discovery->discover($ast) as $slot) { + $this->registerNativeGlobalObject($slot['name'], $slot['class'], $slot['node']); + } + } + + // The resolver closures are bound to the Translator. Release the + // short-lived discovery object before returning so an embedded, + // self-hosted compiler never keeps that object graph alive until + // php_embed_shutdown(). + unset($discovery); + } + public function getSortedFiles(array $list): array { $sorter = new StringSort(); diff --git a/tests/compiler/native-class/clone-phpx-fields.phpt b/tests/compiler/native-class/clone-phpx-fields.phpt new file mode 100644 index 00000000..71d1fb3d --- /dev/null +++ b/tests/compiler/native-class/clone-phpx-fields.phpt @@ -0,0 +1,82 @@ +--TEST-- +Native class: clone preserves PHPX field semantics and traces shared native children +--FILE-- +object = (object) ['value' => 1]; + $source->stream = fopen('php://memory', 'w+'); + $source->mixed = ['mixed']; + $source->child = new NativeCloneChild(); + + $copy = clone $source; + $copy->name = 'copy'; + $copy->values[] = 2; + $copy->object->value = 2; + fwrite($copy->stream, 'shared'); + $copy->mixed[] = 'copy'; + + // Allocate enough garbage to exercise tracing of the cloned Native child. + for ($i = 0; $i < 10000; $i++) { + new NativeCloneChild(); + } + + var_dump($source->name, $source->values, $source->object->value, $source->mixed); + rewind($source->stream); + echo stream_get_contents($source->stream), "\n"; + echo $copy->child->name, "\n"; + return $copy; +} + +function main(): void +{ + $copy = createClone(); + var_dump($copy->name, $copy->values, $copy->object->value, $copy->mixed); +} +?> +--EXPECT-- +string(6) "source" +array(1) { + [0]=> + int(1) +} +int(2) +array(1) { + [0]=> + string(5) "mixed" +} +shared +child +string(4) "copy" +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} +int(2) +array(2) { + [0]=> + string(5) "mixed" + [1]=> + string(4) "copy" +} diff --git a/tests/compiler/native-class/failed-clone-finalizer.phpt b/tests/compiler/native-class/failed-clone-finalizer.phpt new file mode 100644 index 00000000..842ac910 --- /dev/null +++ b/tests/compiler/native-class/failed-clone-finalizer.phpt @@ -0,0 +1,60 @@ +--TEST-- +Native class: an unreachable clone whose __clone throws is finalized by the GC +--FILE-- +kind = 'clone'; + throw new RuntimeException('clone failed'); + } + + public function __destruct() + { + global $finalizedKinds; + $finalizedKinds[] = $this->kind; + } +} + +#[Native] +class NativeFailedClonePressure +{ + public int $value; +} + +function forceFailedCloneCollection(): void +{ + for ($i = 0; $i < 400000; $i++) { + new NativeFailedClonePressure(); + } +} + +function main(): void +{ + global $finalizedKinds; + $finalizedKinds = []; + + $source = new NativeFailedCloneFinalizer(); + try { + clone $source; + } catch (RuntimeException $error) { + echo $error->getMessage(), "\n"; + } + + forceFailedCloneCollection(); + var_dump($finalizedKinds); + echo $source->kind, "\n"; +} +?> +--EXPECT-- +clone failed +array(1) { + [0]=> + string(5) "clone" +} +source diff --git a/tests/compiler/native-class/global-and-static.phpt b/tests/compiler/native-class/global-and-static.phpt index cea1bed0..e491eec6 100644 --- a/tests/compiler/native-class/global-and-static.phpt +++ b/tests/compiler/native-class/global-and-static.phpt @@ -32,6 +32,18 @@ function replaceGlobalWithChild(): void $nativeGlobal->value = 41; } +function clearGlobal(): void +{ + global $nativeGlobal; + $nativeGlobal = null; +} + +function globalIsNull(): bool +{ + global $nativeGlobal; + return is_null($nativeGlobal); +} + function nextStatic(): int { static $counter = new NativeCounter(); @@ -44,6 +56,10 @@ function main(): void var_dump(readGlobal()); replaceGlobalWithChild(); var_dump(readGlobal()); + clearGlobal(); + var_dump(globalIsNull()); + replaceGlobalWithChild(); + var_dump(readGlobal()); var_dump(nextStatic()); var_dump(nextStatic()); } @@ -51,5 +67,7 @@ function main(): void --EXPECT-- int(40) int(41) +bool(true) +int(41) int(1) int(2)