From d1a9397f6cecb8c9090c3292e24e2a9096e7b5dd Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 3 Sep 2026 18:27:13 +0800 Subject: [PATCH] fix: compose trait declarations before conversion (fix gh-80) --- .../code/trait-cross-file-call/a-caller.php | 13 +++ .../code/trait-cross-file-call/z-classes.php | 18 +++ .../code/trait-cross-file-call/zz-trait.php | 11 ++ phpunit/src/TraitCrossFileCallTest.php | 66 +++++++++++ src/Build/SourcePipelineTrait.php | 5 + src/CompilerBase.php | 2 + src/Entity/ConstantDef.php | 2 + src/Preprocessor.php | 92 ++++++++++++++- src/Translator.php | 107 +++++++++++++++++- .../trait/trait-method-magic-fallback.phpt | 33 ++++++ 10 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 phpunit/code/trait-cross-file-call/a-caller.php create mode 100644 phpunit/code/trait-cross-file-call/z-classes.php create mode 100644 phpunit/code/trait-cross-file-call/zz-trait.php create mode 100644 phpunit/src/TraitCrossFileCallTest.php create mode 100644 tests/compiler/trait/trait-method-magic-fallback.phpt diff --git a/phpunit/code/trait-cross-file-call/a-caller.php b/phpunit/code/trait-cross-file-call/a-caller.php new file mode 100644 index 00000000..03ebfdb4 --- /dev/null +++ b/phpunit/code/trait-cross-file-call/a-caller.php @@ -0,0 +1,13 @@ +getAttribute('with-magic'); + + $withoutMagic = new ClassC(); + $withoutMagic->getAttribute('without-magic'); +} diff --git a/phpunit/code/trait-cross-file-call/z-classes.php b/phpunit/code/trait-cross-file-call/z-classes.php new file mode 100644 index 00000000..881b3f05 --- /dev/null +++ b/phpunit/code/trait-cross-file-call/z-classes.php @@ -0,0 +1,18 @@ +addFiles($files); + foreach ($files as $file) { + $compiler->prepareFile($file); + } + + $classDef = $compiler->getClassDef('CrossFileTrait\\ClassB'); + self::assertNotNull($classDef); + self::assertFalse($classDef->hasMethod('getAttribute')); + + // Trait expansion is an explicit declaration phase after all files + // have been prepared and before any body is converted. + $compiler->composeTraitDeclarations($files); + self::assertTrue($classDef->hasMethod('getAttribute')); + $argument = $classDef->getMethod('getAttribute')->functionDef->argInfoList[0]; + self::assertTrue($argument->hasDefaultValue()); + self::assertSame('', $argument->default); + + $generated = $compiler->convertFile($caller); + self::assertNotNull($generated); + $code = file_get_contents($generated); + self::assertIsString($code); + + self::assertStringContainsString( + 'php_crossfiletrait__classb__getattribute(', + $code, + ); + self::assertStringContainsString( + 'php_crossfiletrait__classc__getattribute(', + $code, + ); + self::assertStringNotContainsString( + 'php_crossfiletrait__classb____call(', + $code, + ); + self::assertStringNotContainsString('.call(', $code); + self::assertNotSame('', $argument->default); + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 4514e8a8..fea413bd 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -154,6 +154,10 @@ trait SourcePipelineTrait } } } + // Trait declarations can only be flattened after the complete source + // set has been prepared: a consuming class may precede its Trait file. + // Complete the declaration graph before any body is converted. + $this->composeTraitDeclarations(array_values($files)); // 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. @@ -253,6 +257,7 @@ trait SourcePipelineTrait public function convert(array $files): array { + $this->composeTraitDeclarations($files); $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); try { // All declarations are now known. Lower declaration constant diff --git a/src/CompilerBase.php b/src/CompilerBase.php index a0375752..a437a2ca 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -273,6 +273,7 @@ class CompilerBase implements PropertyAccessContext public const string ENTRY_FUNCTION = 'main'; protected const string PHASE_IDLE = 'idle'; protected const string PHASE_PREPARE = 'prepare'; + protected const string PHASE_COMPOSE = 'compose'; protected const string PHASE_CONVERT = 'convert'; protected string $lang = 'PHP'; @@ -339,6 +340,7 @@ class CompilerBase implements PropertyAccessContext protected int $propertyAccessCacheIndex = 0; /** @var array> Prepared declaration ASTs keyed by real path. */ protected array $preparedFileAsts = []; + protected bool $traitDeclarationsComposed = false; protected bool $declarationExpressionsFinalized = false; protected bool $methodOverrideFlagsFinalized = false; protected const array PHP_RUNTIME_TYPE_MAP = [ diff --git a/src/Entity/ConstantDef.php b/src/Entity/ConstantDef.php index f8ed5d23..89c23469 100644 --- a/src/Entity/ConstantDef.php +++ b/src/Entity/ConstantDef.php @@ -18,6 +18,8 @@ class ConstantDef public string $value; public string $arrayExpr = ''; public string $class = ''; + /** Trait whose lexical namespace/import context owns this declaration. */ + public string $traitOrigin = ''; public ?NodeAbstract $valueExpr = null; /** True after the declaration AST has been lowered to C++ in convert. */ public bool $codegenFinalized = false; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 389f5107..e549f932 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -316,6 +316,7 @@ class Preprocessor extends CompilerBase // constants are validated here, but their C++ expressions are not // generated until the complete symbol table is available. $this->preparedFileAsts[$this->file] = $stmts; + $this->traitDeclarationsComposed = false; $this->declarationExpressionsFinalized = false; // The prepared class graph changed; override flags must be // re-finalized before the next conversion. @@ -351,6 +352,89 @@ class Preprocessor extends CompilerBase } } + /** + * Compose Trait declarations after every source file has been prepared. + * + * A Trait may be declared after the class that uses it, so composition + * cannot be performed safely by prepareFile(). This intermediate phase + * runs against the complete declaration graph and makes the composed + * method/property/constant signatures visible before any function body is + * converted. Expression lowering remains a convert-phase responsibility. + * + * @param list $files + */ + public function composeTraitDeclarations(array $files): void + { + if ($this->traitDeclarationsComposed) { + return; + } + + $previousPhase = $this->enterCompilerPhase(self::PHASE_COMPOSE); + try { + foreach ($files as $file) { + $path = realpath($file); + if ($path === false || !isset($this->preparedFileAsts[$path])) { + continue; + } + $this->loadFile($path); + $this->resetFile(); + $this->resetFunction(); + $this->resetMethod(); + $this->resetClass(); + $this->resetNamespace(); + $this->composeTraitDeclarationStatementList($this->preparedFileAsts[$path]); + } + $this->traitDeclarationsComposed = true; + // Trait methods are real methods of their consuming classes and + // therefore change virtual-dispatch analysis. + $this->methodOverrideFlagsFinalized = false; + } finally { + $this->restoreCompilerPhase($previousPhase); + } + } + + /** @param array $statements */ + private function composeTraitDeclarationStatementList(array $statements): void + { + foreach ($statements as $statement) { + if ($statement instanceof Node\Stmt\Namespace_) { + $this->resetClass(); + $this->resetMethod(); + $this->resetFunction(); + $this->resetNamespace(); + $this->namespace = $statement->name ? $this->parseIdentifier($statement->name) : ''; + $this->composeTraitDeclarationStatementList($statement->stmts); + continue; + } + if ($statement instanceof Node\Stmt\Use_) { + $this->parseUse($statement); + continue; + } + if ($statement instanceof Node\Stmt\GroupUse) { + $this->parseGroupUse($statement); + continue; + } + if ($statement instanceof Node\Stmt\Class_ + || $statement instanceof Node\Stmt\Trait_ + || $statement instanceof Node\Stmt\Enum_ + ) { + $this->resetClass(); + $this->class = $this->parseIdentifier($statement->name); + $this->classDef = $this->getClass($this->getFullClassName()); + $this->composePreparedTraitDeclarations($statement); + } + } + } + + /** + * Translator supplies Trait AST composition; the declaration collector + * owns the phase and source/namespace traversal. + */ + protected function composePreparedTraitDeclarations( + Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class, + ): void { + } + protected function fatalPhpParserError(\PhpParser\Error $error): never { $location = $this->file; @@ -451,7 +535,7 @@ class Preprocessor extends CompilerBase } } - private function finalizeClassDeclarationExpressions( + protected function finalizeClassDeclarationExpressions( Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class, ): void { $this->resetClass(); @@ -528,7 +612,7 @@ class Preprocessor extends CompilerBase $this->interfaceDef = null; } - private function finalizePreparedFunctionDefaults( + protected function finalizePreparedFunctionDefaults( Node\Stmt\Function_|Node\Stmt\ClassMethod $function, FunctionDef $functionDef, ): void { @@ -546,7 +630,7 @@ class Preprocessor extends CompilerBase } } - private function finalizePreparedProperty(PropertyDef $property, Node\Expr $expression): void + protected function finalizePreparedProperty(PropertyDef $property, Node\Expr $expression): void { $this->resetFunction(); $property->arrayInitPlan = null; @@ -558,7 +642,7 @@ class Preprocessor extends CompilerBase } } - private function finalizePreparedConstant(ConstantDef $constant, Node\Expr $expression): void + protected function finalizePreparedConstant(ConstantDef $constant, Node\Expr $expression): void { $this->resetFunction(); $constant->arrayExpr = ''; diff --git a/src/Translator.php b/src/Translator.php index 20dc04e7..b6307b69 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -580,6 +580,10 @@ class Translator extends Preprocessor public function convertFile(string $file): ?string { + // Public embedding/test callers may prepare files directly instead of + // using SourcePipelineTrait::prepare(). Preserve the same explicit + // prepare -> Trait composition -> convert ordering for that API. + $this->composeTraitDeclarations(array_keys($this->preparedFileAsts)); $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); try { if (!$this->declarationExpressionsFinalized) { @@ -3437,6 +3441,9 @@ CODE; $traitMethods[$methodName] = [$traitFullName, $traitStmt]; } if ($traitStmt instanceof Node\Stmt\ClassConst) { + if ($traitStmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE) === null) { + $traitStmt->setAttribute(self::TRAIT_ORIGIN_ATTRIBUTE, $traitFullName); + } foreach ($traitStmt->consts as $k2 => $const) { $constName = strtolower($const->name->toString()); if (isset($constants[$constName])) { @@ -4344,6 +4351,98 @@ CODE; return $code; } + /** + * Inject declarations received through Trait composition during the + * explicit phase between prepare and convert. At this point every Trait + * AST is known, while expression lowering and method-body generation are + * still forbidden. + */ + protected function composePreparedTraitDeclarations( + Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class, + ): void { + if ($class instanceof Node\Stmt\Trait_ || $this->classDef->usedTraits === []) { + return; + } + + /** @var Node\Stmt\Class_|Node\Stmt\Enum_ $composedClass */ + $composedClass = $this->cloneAstNode($class); + $this->composeTraitAst( + $composedClass, + new Node\Name($this->classDef->getNamespacedName(false)), + ); + $this->installComposedTraitDataMembers($composedClass); + + foreach ($composedClass->stmts as $statement) { + if (!$statement instanceof Node\Stmt\ClassMethod + || !is_string($statement->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE))) { + continue; + } + $origin = (string) $statement->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); + $this->withTraitNameContext($origin, function () use ($statement): void { + $this->installComposedTraitMethod($statement); + }); + } + + $this->resetMethod(); + $this->resetFunction(); + } + + /** + * Lower only the declaration expressions belonging to members injected in + * the preceding Trait-composition phase. Their signatures already exist in + * ClassDef; this convert-phase pass does not add declarations or bodies. + */ + protected function finalizeClassDeclarationExpressions( + Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class, + ): void { + parent::finalizeClassDeclarationExpressions($class); + + if ($class instanceof Node\Stmt\Trait_ || $this->classDef->usedTraits === []) { + return; + } + + foreach ($this->classDef->constants as $constant) { + if ($constant->codegenFinalized + || $constant->traitOrigin === '' + || !$constant->valueExpr instanceof Node\Expr + ) { + continue; + } + $this->withTraitNameContext($constant->traitOrigin, function () use ($constant): void { + $this->finalizePreparedConstant($constant, $constant->valueExpr); + }); + } + + foreach ($this->classDef->properties as $property) { + $origin = $property->node?->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); + if (!is_string($origin) || $origin === '' || !$property->defaultExpr instanceof Node\Expr) { + continue; + } + $this->withTraitNameContext($origin, function () use ($property): void { + $this->finalizePreparedProperty($property, $property->defaultExpr); + }); + } + + foreach ([$this->classDef->methods, $this->classDef->abstractMethodDefs] as $methods) { + foreach ($methods as $method) { + if ($method->traitOrigin === '' + || !$method->node instanceof Node\Stmt\ClassMethod + || $method->functionDef === null + ) { + continue; + } + $this->method = $method->name; + $this->methodDef = $method; + $this->withTraitNameContext($method->traitOrigin, function () use ($method): void { + $this->finalizePreparedFunctionDefaults($method->node, $method->functionDef); + }); + } + } + + $this->resetMethod(); + $this->resetFunction(); + } + protected function genNativeMethod(array $methodCodes): string { $code = ''; @@ -6522,6 +6621,12 @@ CODE; foreach ($stmt->consts as $const) { if (!$this->classDef->hasConstant($const->name->toString())) { $this->parseClassConstDef($stmt); + $origin = $stmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); + if (is_string($origin)) { + foreach ($stmt->consts as $installedConst) { + $this->classDef->getConstant($installedConst->name->toString())->traitOrigin = $origin; + } + } break; } } @@ -6547,7 +6652,7 @@ CODE; { $name = $methodStmt->name->toString(); $this->assertNativeMagicMethodSupported($methodStmt, $name); - if ($this->classDef->hasMethod($name)) { + if ($this->classDef->hasMethod($name) || $this->classDef->hasAbstractMethod($name)) { return; } diff --git a/tests/compiler/trait/trait-method-magic-fallback.phpt b/tests/compiler/trait/trait-method-magic-fallback.phpt new file mode 100644 index 00000000..db8b4ffb --- /dev/null +++ b/tests/compiler/trait/trait-method-magic-fallback.phpt @@ -0,0 +1,33 @@ +--TEST-- +Trait methods take precedence over __call magic fallback +--FILE-- +getAttribute('SomeClass')); + var_dump($object->missingMethod()); +} +?> +--EXPECT-- +string(19) "attribute:SomeClass" +string(19) "magic:missingMethod"