From 322bb8ce5aa72743ea951f96ccadd14a0910ba73 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 19:49:50 +0000 Subject: [PATCH 01/36] feat(parser): align call-site syntax with PHP RFC bound-erased generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track php-rfc/bound_erased_generic_types so .xphp sources written today stay valid against a future RFC-PHP runtime. At call/`new` sites the parser now requires the turbofish `Name::<…>` (with byte-level adjacency between `::` and `<`); bare `Name<…>(...)` is left un-stripped so PHP surfaces the syntax error rather than xphp silently specializing a form the future runtime would refuse. Declaration sites and type-hint positions keep bare `<…>` -- the RFC accepts both there. Variance docs flip to RFC-style `+T` / `-T` (and the multi-bound example to `\Stringable & \Countable`) so future xphp variance work doesn't have to revisit. All 16 compile-fixture call sites + 5 integration-test inline sources rewritten to turbofish; 9 new parser tests lock the recognition rules. Unit suite 194/194 green, Infection MSI 100%. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/compiler.md | 12 +- docs/roadmap.md | 1 + docs/type-system/comparison.md | 26 +- docs/type-system/generics/index.md | 16 +- .../Monomorphize/XphpSourceParser.php | 82 ++++-- .../BoundedGenericIntegrationTest.php | 4 +- .../GenericFunctionIntegrationTest.php | 2 +- .../GenericMethodIntegrationTest.php | 4 +- .../Monomorphize/XphpSourceParserTest.php | 254 +++++++++++++++++- .../compile/array_sugar/source/Use.xphp | 2 +- .../compile/bounds_happy/source/Use.xphp | 2 +- .../compile/box_generic/source/Use.xphp | 6 +- .../fixture/compile/depth_cap/source/Use.xphp | 2 +- .../compile/generic_function/source/Use.xphp | 4 +- .../source/Use.xphp | 2 +- .../source/funcs.xphp | 2 +- .../compile/generic_interface/source/Use.xphp | 4 +- .../compile/generic_method/source/Use.xphp | 6 +- .../multi_type/source/Containers/Pair.xphp | 2 +- .../compile/multi_type/source/Use.xphp | 8 +- .../nested_instantiation/source/Use.xphp | 4 +- .../source/Containers/Wrapper.xphp | 2 +- .../compile/nested_typehint/source/Use.xphp | 2 +- 24 files changed, 369 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 2be9534..8b3243a 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ class Collection { // /main.xphp namespace App; -$users = new Collection( +$users = new Collection::( new User('Alice'), new User('Bob') ); diff --git a/docs/compiler.md b/docs/compiler.md index 1e8c911..e2b2cde 100644 --- a/docs/compiler.md +++ b/docs/compiler.md @@ -174,7 +174,7 @@ below. ## Phase 3 -- Method- and function-scope specialization -Method-scoped generics (`Cls::method(...)`) and free generic +Method-scoped generics (`Cls::method::(...)`) and free generic functions (`function f(...)`) are handled by a separate pass before the class-level fixed-point loop runs. The reason is that their specialization is **call-site driven**: each unique arg list @@ -198,8 +198,8 @@ runs on the full `astPerFile` map and: 4. Strips the original template `ClassMethod` / `Function_`. 5. Rewrites each call site's identifier to the mangled name. -**MVP scope:** static calls only (`Cls::method(...)`) -- instance -calls `$obj->method(...)` are deferred (the compiler can't pick +**MVP scope:** static calls only (`Cls::method::(...)`) -- instance +calls `$obj->method::(...)` are deferred (the compiler can't pick the receiver class without proper type inference). Method-scoped generics are also restricted to methods on non-generic enclosing classes; combining method-level and class-level type-params would @@ -261,7 +261,7 @@ Two transformations happen during rewrite, both implemented in 1. **Generic Name nodes become FullyQualified references.** Every Name node carrying `ATTR_GENERIC_ARGS` (with all args fully concrete) is replaced with a `FullyQualified` Name pointing at - the Registry's generated FQN. This catches `new Box(...)`, + the Registry's generated FQN. This catches `new Box::(...)`, `Box $b`, `function f(): Box`, and similar -- every position where a generic instantiation can appear. 2. **Generic ClassLike definitions become empty marker interfaces.** @@ -292,13 +292,13 @@ the final step. Bound checks happen inside the Registry when an instantiation is recorded. The validation is integrated into the recording flow so violations fire at the source-level instantiation (e.g. -`new Box()`), not later when the obfuscated `T_` name +`new Box::()`), not later when the obfuscated `T_` name shows up. ```mermaid sequenceDiagram participant SRC as Source line - Note right of SRC: new Box(...) + Note right of SRC: new Box::(...) participant XSP as XphpSourceParser participant RC as RegistryCollector participant R as Registry diff --git a/docs/roadmap.md b/docs/roadmap.md index 233a0ec..ef4a695 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,6 +31,7 @@ timeline : XPHP_HASH_LENGTH configurable (16..64) Developer experience : PSR-4 fixtures + : RFC-aligned call-site syntax (`Name::<...>` turbofish per php-rfc/bound_erased_generic_types) section Next Type system depth : default type parameters diff --git a/docs/type-system/comparison.md b/docs/type-system/comparison.md index 61c5000..69b4aad 100644 --- a/docs/type-system/comparison.md +++ b/docs/type-system/comparison.md @@ -38,19 +38,21 @@ These are obvious gaps with clear value. ### 1. Variance annotations -TypeScript (`in` / `out`), Kotlin (`in` / `out`), Rust (implicit via lifetimes -and `PhantomData`). +PHP RFC [bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) +proposes prefix markers `+T` (covariant) / `-T` (contravariant). TypeScript / +Kotlin spell the same idea as `in` / `out`; Rust handles it implicitly via +lifetimes and `PhantomData`. ```php // covariant -class Producer { +class Producer<+T> { public function get(): T { /*... */ } -} +} // contravariant -class Consumer { +class Consumer<-T> { public function set(T $x) { /* ... */ } -} +} ``` Currently, every `Box` and `Box` is unrelated even when @@ -58,7 +60,7 @@ Currently, every `Box` and `Box` is unrelated even when With marker interfaces the only commonality is the erased `Box`. -Adding `out T` would let the compiler emit +Adding `+T` would let the compiler emit `Box_ implements Box_` when `Banana <: Fruit` -- a real subtype relationship at the specialized FQN level. @@ -76,7 +78,7 @@ class Cache { /* ... */ } $default = new Cache(); /* @var Cache $userCache */ -$userCache = new Cache(); +$userCache = new Cache::(); ``` Trivial to add: the scanner already parses param entries; just allow `= TypeRef` @@ -88,9 +90,11 @@ defaults. - TypeScript: `T extends A & B` - Kotlin: `where T : A, T : B` - Rust: `T: A + B` +- PHP RFC: any valid type expression -- unions (`A | B`), intersections + (`A & B`), or DNF -- as the bound ```php -class Sortable { /* ... */ } +class Sortable { /* ... */ } ``` Bound validation (`Registry::checkBounds`) already loops per param at both the @@ -99,8 +103,8 @@ class-instantiation and method-call sites; trivially extends to loop per ### 4. Instance-method generic calls -Currently: only `Util::method(...)` (static call on a non-generic enclosing -class) is supported. `$obj->method(...)` requires knowing the static type of +Currently: only `Util::method::(...)` (static call on a non-generic enclosing +class) is supported. `$obj->method::(...)` requires knowing the static type of `$obj` to pick the receiver class. With strict typing on parameters and properties, the static type is usually known at the call site; the unsolved part is the dispatch table when `$obj` is a union / intersection / interface. diff --git a/docs/type-system/generics/index.md b/docs/type-system/generics/index.md index c08f906..df60e50 100644 --- a/docs/type-system/generics/index.md +++ b/docs/type-system/generics/index.md @@ -161,18 +161,22 @@ or include the type in the source set. list mints one mangled specialization (`NAME_T_`) appended to the same class; call sites rewrite to the mangled name. Specializations are deduped -- two -`identity` calls share one method body. +`identity::` calls share one method body. + +Call sites use the [RFC bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) +turbofish `::<...>`, which is forward-compatible with the proposed native PHP +syntax. ```php class Util { public static function identity(T $x): T { return $x; } } -Util::identity(42); // -> Util::identity_T_(42) -Util::identity('hi'); // -> Util::identity_T_('hi') +Util::identity::(42); // -> Util::identity_T_(42) +Util::identity::('hi'); // -> Util::identity_T_('hi') ``` -MVP limits: static-call sites only (`Util::method<…>`); method must be on a +MVP limits: static-call sites only (`Util::method::<…>`); method must be on a non-generic enclosing class. Bound checks on method-level type-params are enforced at call time (same `Registry::checkBounds` machinery as class-level bounds). @@ -195,8 +199,8 @@ and every specialization `implements` (or `extends`, for interfaces) it. So `true` for any `Box<…>` specialization, no concrete arg list required. ```php -$x = new Box(); -$y = new Box(); +$x = new Box::(); +$y = new Box::(); $x instanceof App\Containers\Box; // true $y instanceof App\Containers\Box; // true ``` diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a8f3ad0..a396912 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -39,9 +39,15 @@ * introduces or removes a newline, so line numbers — which is how markers are matched to AST * nodes — stay stable. * + * Call-site syntax follows PHP RFC bound_erased_generic_types: `Name::(...)` + * turbofish at every expression-context call (`new`, free function, static method, + * instance method). Bare `Name(...)` at a call site is rejected so the source + * fails to compile rather than silently specializing into a form the future PHP + * runtime would refuse. Declaration sites (`class Box`) and type-hint positions + * (`Box $b`, `: Box`, `extends Box`) stay bare -- the RFC accepts both. + * * MVP limitations: * - Generic syntax inside strings/comments is correctly ignored (tokenizer handles it). - * - Generic syntax with constraints (`T: SomeInterface`) is not supported. * - `Name[]` (array of a generic) is not supported — generics-after-array-sugar would * need extra wiring; users get a native PHP parse error today. */ @@ -232,31 +238,75 @@ private function scanAndStrip(string $source): array if (self::isNameToken($tok)) { $nameText = $tok->text; $nameLine = $tok->line; - // For member-access call sites (`Foo::method<…>`, `$x->method<…>`, - // `$x?->method<…>`), walk back past the operator to the receiver and + // For member-access call sites (`Foo::method::<…>`, `$x->method::<…>`, + // `$x?->method::<…>`), walk back past the operator to the receiver and // record its line as the marker's anchor. nikic sets a MethodCall / // StaticCall's getStartLine() to the leftmost token in the chain — so // matching against just the identifier's line breaks the moment the - // operator+name are split across lines, e.g. `Foo::\n method`. + // operator+name are split across lines, e.g. `Foo::\n method::`. // The resolver matches if startLine ∈ [anchorLine, line]. $anchorLine = self::memberAccessReceiverLine($tokens, $i) ?? $nameLine; $j = self::skipWs($tokens, $i + 1); + + // Turbofish `Name::<…>` -- the RFC-mandated call-site form. + // Whitespace-sensitive between `::` and `<`: enforced by requiring the + // `<` token's byte position to sit immediately after `::`. Whitespace + // between the Name and `::` is fine (PHP allows it for member access). + if ($j < $n && $tokens[$j]->id === T_DOUBLE_COLON) { + $dcTok = $tokens[$j]; + $afterDc = $j + 1; + if ($afterDc < $n + && $tokens[$afterDc]->text === '<' + && $tokens[$afterDc]->pos === $dcTok->pos + 2 + ) { + $parsed = self::parseTypeArgList($tokens, $afterDc); + if ($parsed !== null) { + [$args, $endIdx] = $parsed; + $nameMarkers[] = [ + 'line' => $nameLine, + 'anchorLine' => $anchorLine, + 'name' => ltrim($nameText, '\\'), + 'args' => $args, + ]; + // Strip from `::` start through `>` end so the cleaned + // source reads as a plain `Name(...)` / `Recv::Name(...)` + // / `$obj->Name(...)` call. + $startByte = $dcTok->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; + } + } + } + + // Bare `Name<…>` is only valid in type-hint position (param/return/ + // property types, `extends` / `implements` clauses). In expression + // context bare `<` is comparison; call sites must use the `::<…>` + // turbofish per RFC. Heuristic: if the `>` is followed by `(`, it's + // a call site -- reject so the downstream PHP parser surfaces the + // error rather than xphp silently specializing a now-invalid form. if ($j < $n && $tokens[$j]->text === '<') { $parsed = self::parseTypeArgList($tokens, $j); if ($parsed !== null) { [$args, $endIdx] = $parsed; - $nameMarkers[] = [ - 'line' => $nameLine, - 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), - 'args' => $args, - ]; - $startByte = $tokens[$j]->pos; - $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); - $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; - $i = $endIdx + 1; - continue; + $afterClose = self::skipWs($tokens, $endIdx + 1); + $isCallSite = $afterClose < $n && $tokens[$afterClose]->text === '('; + if (!$isCallSite) { + $nameMarkers[] = [ + 'line' => $nameLine, + 'anchorLine' => $anchorLine, + 'name' => ltrim($nameText, '\\'), + 'args' => $args, + ]; + $startByte = $tokens[$j]->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; + } } } diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index 3ee2b30..5e3b691 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -71,7 +71,7 @@ class Box file_put_contents($useFile, <<<'PHP' (); + $x = new Box::(); PHP); $compiler = $this->buildCompiler(); @@ -103,7 +103,7 @@ class Box namespace App; // Unknown\Thing isn't in any source file and isn't a built-in PHP type, // so the hierarchy can't prove it satisfies \Stringable. - $x = new Box(); + $x = new Box::(); PHP); $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index 8446d3e..74b2926 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -116,7 +116,7 @@ function describe(T $x): string { return (string) $x; } file_put_contents($usePath, <<<'PHP' (42); + $out = describe::(42); PHP); $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 9f5086f..cf20cab 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -137,7 +137,7 @@ public static function describe(T $x): string { return (string) (42); + $out = Util::describe::(42); PHP); $compiler = $this->buildCompiler(); @@ -185,7 +185,7 @@ public static function identity(T $x): T { return $x; } declare(strict_types=1); namespace App; $asInt = Util:: - identity(42); + identity::(42); PHP); $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 053b721..a18b946 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -184,7 +184,7 @@ public function testAttachesGenericArgsToNewExpressionResolvedAgainstNamespace() (); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -200,7 +200,7 @@ public function testResolvesGenericArgViaUseStatement(): void use App\Models\Plastic; -$x = new Box(); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -211,7 +211,7 @@ public function testHandlesMultipleTypeArgs(): void { $source = <<<'PHP' (); +$x = new Map::(); PHP; $args = self::parseAndGetArgs($source, 'Map'); self::assertCount(2, $args); @@ -230,7 +230,7 @@ public function testHandlesNestedGenericArgs(): void use App\Containers\Lst; use App\Models\Plastic; -$x = new Box>(); +$x = new Box::>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -305,7 +305,7 @@ public function testResolvesGenericArgViaMultiSegmentUseAlias(): void use App\Models; use App\Containers\Box; -$x = new Box(); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -325,7 +325,7 @@ public function testResolvesGenericArgViaUseAliasWithDifferentRootNamespace(): v use Vendor\Lib; -$x = new Box(); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -344,7 +344,7 @@ public function testResolvesTemplateFqnViaMultiSegmentUseAlias(): void use App\Containers; use App\Models\Plastic; -$x = new Containers\Box(); +$x = new Containers\Box::(); PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); @@ -521,7 +521,7 @@ public function testNameMarkerRequiresBothLineAndNameMatch(): void $source = <<<'PHP' ())->x; +$y = Foo::method() + (new Box::())->x; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); @@ -583,7 +583,7 @@ public function testFullyQualifiedArgIsResolvedExactly(): void (); +$x = new Box::<\Vendor\Foreign>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -602,7 +602,7 @@ public function testFullyQualifiedTemplateNameIsRecognized(): void use App\Models\Plastic; -$x = new \App\Containers\Box(); +$x = new \App\Containers\Box::(); PHP; $args = self::parseAndGetArgs($source, 'App\\Containers\\Box'); self::assertCount(1, $args); @@ -618,7 +618,7 @@ public function testFullyQualifiedNamesInNestedGenericArg(): void >(); +$x = new Box::<\Vendor\Lst<\Vendor\Plastic>>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -647,7 +647,7 @@ public function testRelativeNamespaceQualifiedNameIsRecognizedByScanner(): void (); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertNotEmpty( @@ -673,7 +673,7 @@ public function testUseStatementWithExplicitAliasResolvesViaAlias(): void use Vendor\Plastic as Material; -$x = new Box(); +$x = new Box::(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); @@ -1071,6 +1071,234 @@ public function testParseWithMapIsCallableFromOutsideTheClass(): void self::assertInstanceOf(ByteOffsetMap::class, $byteOffsetMap); } + // =================================================================== + // RFC bound_erased_generic_types: `::<…>` turbofish at call/`new` sites, + // bare `<…>` at type-hint sites only. + // =================================================================== + + public function testTurbofishOnFreeFunctionCallIsRecognized(): void + { + $source = <<<'PHP' +(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Cleaned source must drop both `::` and `` so PHP sees `identity(42)`. + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::<', $stripped); + self::assertStringNotContainsString('', $stripped); + self::assertStringContainsString('identity', $stripped); + + // And the resolver attaches the type-args to the FuncCall node. + $ast = $parser->parse($source); + $funcCall = self::findFirstNodeOfType($ast, Node\Expr\FuncCall::class); + self::assertNotNull($funcCall); + $args = $funcCall->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('int', $args[0]->name); + self::assertTrue($args[0]->isScalar); + } + + public function testTurbofishOnStaticMethodCallIsRecognized(): void + { + $source = <<<'PHP' +(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::<', $stripped); + self::assertStringNotContainsString('', $stripped); + self::assertStringContainsString('Util::identity', $stripped); + + $ast = $parser->parse($source); + $call = self::findFirstNodeOfType($ast, Node\Expr\StaticCall::class); + self::assertNotNull($call); + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('int', $args[0]->name); + } + + public function testTurbofishOnInstanceMethodCallStripsButHasNoResolverYet(): void + { + // Instance-method generic specialization is on the roadmap but not yet + // wired (no MethodCall branch in the resolver). The scanner still has to + // strip the `::<…>` so the cleaned source is plain PHP -- otherwise the + // file would refuse to parse at all. + $source = <<<'PHP' +map::($fn); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::<', $stripped); + self::assertStringNotContainsString('', $stripped); + self::assertStringContainsString('$obj->map', $stripped); + + // Sanity: the cleaned source actually parses as PHP. + $ast = $parser->parse($source); + $call = self::findFirstNodeOfType($ast, Node\Expr\MethodCall::class); + self::assertNotNull($call); + } + + public function testTurbofishOnNullsafeInstanceMethodCallIsStripped(): void + { + $source = <<<'PHP' +map::($fn); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::<', $stripped); + self::assertStringNotContainsString('', $stripped); + self::assertStringContainsString('$obj?->map', $stripped); + } + + public function testBareNewCallSiteIsRejectedAndLeftUnstripped(): void + { + // Bare `new Box()` at expression context is not RFC-compliant; + // the scanner must NOT strip the `<…>` so downstream PHP surfaces the + // syntax error rather than xphp silently specializing the call. + $source = <<<'PHP' +(); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringContainsString('', $stripped, 'bare `new Name<…>()` must be left un-stripped'); + } + + public function testBareFreeFunctionCallIsRejectedAndLeftUnstripped(): void + { + $source = <<<'PHP' +(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringContainsString('', $stripped, 'bare `name<…>()` free-function call must be left un-stripped'); + } + + public function testBareStaticMethodCallIsRejectedAndLeftUnstripped(): void + { + $source = <<<'PHP' +(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringContainsString('', $stripped, 'bare `Recv::method<…>()` static call must be left un-stripped'); + } + + public function testWhitespaceBetweenDoubleColonAndAngleDefeatsTurbofish(): void + { + // The RFC turbofish is whitespace-sensitive: `Foo:: ` is `Foo::` + // (an incomplete static reference) followed by `` (comparison) -- + // not a turbofish. The scanner enforces this by requiring the `<` + // token's byte offset to sit at `::pos + 2` (no gap). + $source = <<<'PHP' +(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringContainsString('', $stripped, 'whitespace between `::` and `<` must defeat turbofish recognition'); + } + + public function testTypeHintPositionAcceptsFullyQualifiedOuterName(): void + { + // Locks the ltrim('\\') on the bare-`<…>` (type-hint) branch: when the + // outer Name is fully qualified (`\App\Containers\Box`), the marker + // must be keyed by the trimmed form so the resolver -- which sees the + // AST Name's `toString()` (no leading backslash) -- can match. + $source = <<<'PHP' + $b; +} +PHP; + $args = self::parseAndGetArgs($source, 'App\\Containers\\Box'); + self::assertCount(1, $args); + self::assertSame('App\\Models\\Plastic', $args[0]->name); + } + + public function testTypeHintPositionStillAcceptsBareGenericArgs(): void + { + // Regression: type-hint sites (property type, return type, params, + // `extends` / `implements`) keep bare `<…>`. The trailing-`(` + // heuristic that rejects call-site bare-`<…>` must not misfire here. + $source = <<<'PHP' + $b; + public function get(): Box { return $this->b; } + public function set(Box $b): void { $this->b = $b; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + // All three `` clauses (property, return, param) are at + // type-hint sites and must be stripped. + self::assertStringNotContainsString('', $stripped); + // And neither original `Box` token should have leaked into a turbofish form. + self::assertStringNotContainsString('::<', $stripped); + } + + /** + * @template TNode of Node + * @param array $ast + * @param class-string $kind + * @return TNode|null + */ + private static function findFirstNodeOfType(array $ast, string $kind): ?Node + { + $found = null; + $walker = function ($nodes) use (&$walker, &$found, $kind): void { + foreach ($nodes as $node) { + if ($found !== null) { + return; + } + if ($node instanceof $kind) { + $found = $node; + return; + } + if (is_object($node) && method_exists($node, 'getSubNodeNames')) { + foreach ($node->getSubNodeNames() as $name) { + $value = $node->$name; + if (is_array($value)) { + $walker($value); + } elseif (is_object($value)) { + $walker([$value]); + } + } + } + } + }; + $walker($ast); + return $found; + } + // =================================================================== // Helpers for the new tests above // =================================================================== diff --git a/test/fixture/compile/array_sugar/source/Use.xphp b/test/fixture/compile/array_sugar/source/Use.xphp index 045f442..5c55a49 100644 --- a/test/fixture/compile/array_sugar/source/Use.xphp +++ b/test/fixture/compile/array_sugar/source/Use.xphp @@ -7,6 +7,6 @@ namespace App\ArraySugar; use App\ArraySugar\Containers\Collection; use App\ArraySugar\Models\User; -$users = new Collection(new User('Alice'), new User('Bob')); +$users = new Collection::(new User('Alice'), new User('Bob')); $first = $users->first(); $all = $users->all(); diff --git a/test/fixture/compile/bounds_happy/source/Use.xphp b/test/fixture/compile/bounds_happy/source/Use.xphp index 13e7819..5147436 100644 --- a/test/fixture/compile/bounds_happy/source/Use.xphp +++ b/test/fixture/compile/bounds_happy/source/Use.xphp @@ -7,4 +7,4 @@ namespace App\BoundsHappy; use App\BoundsHappy\Containers\Box; use App\BoundsHappy\Models\Tag; -$tagBox = new Box(new Tag('hello')); +$tagBox = new Box::(new Tag('hello')); diff --git a/test/fixture/compile/box_generic/source/Use.xphp b/test/fixture/compile/box_generic/source/Use.xphp index 6de8ec3..748fde4 100644 --- a/test/fixture/compile/box_generic/source/Use.xphp +++ b/test/fixture/compile/box_generic/source/Use.xphp @@ -8,10 +8,10 @@ use App\BoxGeneric\Containers\Box; use App\BoxGeneric\Models\Metal; use App\BoxGeneric\Models\Plastic; -$plasticBox = new Box(); +$plasticBox = new Box::(); $plasticBox->set(new Plastic('red')); -$metalBox = new Box(); +$metalBox = new Box::(); $metalBox->set(new Metal(7)); -$secondPlasticBox = new Box(); +$secondPlasticBox = new Box::(); diff --git a/test/fixture/compile/depth_cap/source/Use.xphp b/test/fixture/compile/depth_cap/source/Use.xphp index be1fb87..ed014ff 100644 --- a/test/fixture/compile/depth_cap/source/Use.xphp +++ b/test/fixture/compile/depth_cap/source/Use.xphp @@ -6,4 +6,4 @@ namespace App\DepthCap; use App\DepthCap\Containers\Recursive; -$x = new Recursive(); +$x = new Recursive::(); diff --git a/test/fixture/compile/generic_function/source/Use.xphp b/test/fixture/compile/generic_function/source/Use.xphp index 169c5ff..edc3f7c 100644 --- a/test/fixture/compile/generic_function/source/Use.xphp +++ b/test/fixture/compile/generic_function/source/Use.xphp @@ -4,5 +4,5 @@ declare(strict_types=1); namespace App\GenericFunction; -$asInt = identity(42); -$asString = identity('world'); +$asInt = identity::(42); +$asString = identity::('world'); diff --git a/test/fixture/compile/generic_function_nested_args/source/Use.xphp b/test/fixture/compile/generic_function_nested_args/source/Use.xphp index bf509db..951119a 100644 --- a/test/fixture/compile/generic_function_nested_args/source/Use.xphp +++ b/test/fixture/compile/generic_function_nested_args/source/Use.xphp @@ -4,4 +4,4 @@ declare(strict_types=1); namespace App\GenericFunctionNestedArgs; -$boxed = wrap(42); +$boxed = wrap::(42); diff --git a/test/fixture/compile/generic_function_nested_args/source/funcs.xphp b/test/fixture/compile/generic_function_nested_args/source/funcs.xphp index 71e7d48..de3f509 100644 --- a/test/fixture/compile/generic_function_nested_args/source/funcs.xphp +++ b/test/fixture/compile/generic_function_nested_args/source/funcs.xphp @@ -8,5 +8,5 @@ use App\GenericFunctionNestedArgs\Containers\Box; function wrap(T $x): Box { - return new Box($x); + return new Box::($x); } diff --git a/test/fixture/compile/generic_interface/source/Use.xphp b/test/fixture/compile/generic_interface/source/Use.xphp index 18c1661..b6aaced 100644 --- a/test/fixture/compile/generic_interface/source/Use.xphp +++ b/test/fixture/compile/generic_interface/source/Use.xphp @@ -8,5 +8,5 @@ use App\GenericInterface\Containers\Box; use App\GenericInterface\Models\Plastic; use App\GenericInterface\Models\Polymer; -$plasticBox = new Box(new Plastic('red')); -$polymerBox = new Box(new Polymer('PET')); +$plasticBox = new Box::(new Plastic('red')); +$polymerBox = new Box::(new Polymer('PET')); diff --git a/test/fixture/compile/generic_method/source/Use.xphp b/test/fixture/compile/generic_method/source/Use.xphp index 5433dac..45559fb 100644 --- a/test/fixture/compile/generic_method/source/Use.xphp +++ b/test/fixture/compile/generic_method/source/Use.xphp @@ -4,6 +4,6 @@ declare(strict_types=1); namespace App\GenericMethod; -$asInt = Util::identity(42); -$asString = Util::identity('hello'); -$asIntAgain = Util::identity(7); +$asInt = Util::identity::(42); +$asString = Util::identity::('hello'); +$asIntAgain = Util::identity::(7); diff --git a/test/fixture/compile/multi_type/source/Containers/Pair.xphp b/test/fixture/compile/multi_type/source/Containers/Pair.xphp index cd38261..03a5700 100644 --- a/test/fixture/compile/multi_type/source/Containers/Pair.xphp +++ b/test/fixture/compile/multi_type/source/Containers/Pair.xphp @@ -14,6 +14,6 @@ class Pair public function swap(): Pair { - return new Pair($this->second, $this->first); + return new Pair::($this->second, $this->first); } } diff --git a/test/fixture/compile/multi_type/source/Use.xphp b/test/fixture/compile/multi_type/source/Use.xphp index 1495961..d31729e 100644 --- a/test/fixture/compile/multi_type/source/Use.xphp +++ b/test/fixture/compile/multi_type/source/Use.xphp @@ -10,14 +10,14 @@ use App\MultiType\Models\Plastic; use App\MultiType\Models\User; // Two distinct classes as type params -$userPlastic = new Pair(new User('alice'), new Plastic('red')); +$userPlastic = new Pair::(new User('alice'), new Plastic('red')); // Same class twice -$plasticPair = new Pair(new Plastic('green'), new Plastic('blue')); +$plasticPair = new Pair::(new Plastic('green'), new Plastic('blue')); // Mixed scalar + class -$nameAge = new Map(); +$nameAge = new Map::(); $nameAge->set('alice', 30); // Nested multi-type: a Pair where each side is itself a generic instantiation -$nested = new Pair, Pair>($nameAge, $userPlastic->swap()); +$nested = new Pair::, Pair>($nameAge, $userPlastic->swap()); diff --git a/test/fixture/compile/nested_instantiation/source/Use.xphp b/test/fixture/compile/nested_instantiation/source/Use.xphp index 51a4478..9659557 100644 --- a/test/fixture/compile/nested_instantiation/source/Use.xphp +++ b/test/fixture/compile/nested_instantiation/source/Use.xphp @@ -8,9 +8,9 @@ use App\NestedInstantiation\Containers\Box; use App\NestedInstantiation\Containers\Lst; use App\NestedInstantiation\Models\Plastic; -$boxOfList = new Box>(); +$boxOfList = new Box::>(); -$inner = new Lst(); +$inner = new Lst::(); $inner->push(new Plastic('red')); $boxOfList->set($inner); diff --git a/test/fixture/compile/nested_typehint/source/Containers/Wrapper.xphp b/test/fixture/compile/nested_typehint/source/Containers/Wrapper.xphp index 1fef414..a7dafcb 100644 --- a/test/fixture/compile/nested_typehint/source/Containers/Wrapper.xphp +++ b/test/fixture/compile/nested_typehint/source/Containers/Wrapper.xphp @@ -12,7 +12,7 @@ class Wrapper public function __construct() { - $this->box = new Box(); + $this->box = new Box::(); } public function setBoxed(T $val): void diff --git a/test/fixture/compile/nested_typehint/source/Use.xphp b/test/fixture/compile/nested_typehint/source/Use.xphp index 46fb0bf..07dca18 100644 --- a/test/fixture/compile/nested_typehint/source/Use.xphp +++ b/test/fixture/compile/nested_typehint/source/Use.xphp @@ -7,5 +7,5 @@ namespace App\NestedTypehint; use App\NestedTypehint\Containers\Wrapper; use App\NestedTypehint\Models\Plastic; -$wrapper = new Wrapper(); +$wrapper = new Wrapper::(); $wrapper->setBoxed(new Plastic('blue')); From f8d3ab7ed5d1971e9a89182288a8f58c21f2985c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 20:30:48 +0000 Subject: [PATCH 02/36] =?UTF-8?q?fix(parser):=20reject=20parenless=20`new?= =?UTF-8?q?=20Name<=E2=80=A6>`=20+=20flag=20unimplemented=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: the bare-`<…>` call-site rejection only triggered on trailing `(`, so `new Box;` (PHP-legal parenless `new`) silently specialized -- a form the RFC turbofish requirement would refuse. Widen `$isCallSite` with an `isPrecededByNew()` lookback (same flat-walk shape as the existing member-access helpers); lock the fix with `testBareNewWithoutParensIsRejectedAndLeftUnstripped`. Doc / roadmap follow-ups: section-level "Status: not yet parsed" notice on `## Tier 1 gaps` in comparison.md so readers don't mistake the `+T`/`-T` variance and `A & B` intersection-bound snippets for live syntax; roadmap bullets refined to spell the same RFC tokens. Inline `@todo` on the turbofish marker append noting that `MethodCall` resolver coverage for instance-method generic calls is pending. Review items 5 (commit-message test count cosmetic) and 6 (migration diagnostic implementation) left as roadmap-only per the original hard-switch decision. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/roadmap.md | 5 ++- docs/type-system/comparison.md | 5 +++ .../Monomorphize/XphpSourceParser.php | 45 +++++++++++++++++-- .../Monomorphize/XphpSourceParserTest.php | 19 ++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index ef4a695..2411c00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,8 +35,8 @@ timeline section Next Type system depth : default type parameters - : multiple bounds (T must satisfy A and B) - : variance annotations (covariant / contravariant) — leverages monomorphization for real subtype edges + : multiple bounds (T satisfies `A & B` per RFC intersection types) + : variance annotations (`+T` / `-T` per RFC) — leverages monomorphization for real subtype edges : reified T as documented contract (T-class / instanceof T / is_a) : F-bounded recursion (T bounded by a generic of itself) Generic surface @@ -47,6 +47,7 @@ timeline Developer experience : Composer plugin for autoload registration : Live transpilation via stream wrapper (no build step) + : Compile-time migration hint for bare-`Name<…>(` call sites (point users at the `::<…>` turbofish) : Phpdoc substitution in generated bodies section Long-term Type system breadth diff --git a/docs/type-system/comparison.md b/docs/type-system/comparison.md index 69b4aad..9e5ced7 100644 --- a/docs/type-system/comparison.md +++ b/docs/type-system/comparison.md @@ -36,6 +36,11 @@ p.s. `n/a` meaning it doesn't fit the language's design. These are obvious gaps with clear value. +> **Status**: every snippet in this section shows the *intended* RFC-aligned +> syntax that `xphp` does not yet parse. Until the corresponding implementation +> lands, the parser rejects these forms with a PHP-side `Syntax error, +> unexpected '<'`. Tracked in [`../roadmap.md`](../roadmap.md). + ### 1. Variance annotations PHP RFC [bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a396912..48733de 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -262,6 +262,10 @@ private function scanAndStrip(string $source): array $parsed = self::parseTypeArgList($tokens, $afterDc); if ($parsed !== null) { [$args, $endIdx] = $parsed; + // @todo MethodCall resolver branch is pending; for + // `$obj->m::<…>(...)` the marker is recorded but + // not claimed today -- the strip on its own is + // enough to keep the cleaned source valid PHP. $nameMarkers[] = [ 'line' => $nameLine, 'anchorLine' => $anchorLine, @@ -284,15 +288,18 @@ private function scanAndStrip(string $source): array // Bare `Name<…>` is only valid in type-hint position (param/return/ // property types, `extends` / `implements` clauses). In expression // context bare `<` is comparison; call sites must use the `::<…>` - // turbofish per RFC. Heuristic: if the `>` is followed by `(`, it's - // a call site -- reject so the downstream PHP parser surfaces the - // error rather than xphp silently specializing a now-invalid form. + // turbofish per RFC. Heuristic: the position is an expression-context + // call site if the `>` is followed by `(` (function-call open) OR if + // the Name is preceded by `new` (catches the parenless `new Foo;` + // and `new Foo` shapes that PHP accepts but the RFC turbofish + // requirement refuses). Type-hint sites match neither check. if ($j < $n && $tokens[$j]->text === '<') { $parsed = self::parseTypeArgList($tokens, $j); if ($parsed !== null) { [$args, $endIdx] = $parsed; $afterClose = self::skipWs($tokens, $endIdx + 1); - $isCallSite = $afterClose < $n && $tokens[$afterClose]->text === '('; + $isCallSite = ($afterClose < $n && $tokens[$afterClose]->text === '(') + || self::isPrecededByNew($tokens, $i); if (!$isCallSite) { $nameMarkers[] = [ 'line' => $nameLine, @@ -548,6 +555,36 @@ private static function isMemberAccessContext(array $tokens, int $nameIdx): bool || $tokens[$i]->id === T_DOUBLE_COLON; } + /** + * Returns true when the Name token at `$nameIdx` is preceded by `new` (with + * optional whitespace / comments in between). + * + * Used to reject bare `<…>` at `new`-expression sites that don't have a + * trailing `(` -- specifically `new Foo;` and `new Foo` shapes that + * PHP itself allows. Without this check, the bare-`<…>` heuristic only + * catches the parens-bearing form (`new Foo()`), so the parenless + * variants slip through and xphp silently specializes a form the RFC + * turbofish requirement would refuse. + * + * @infection-ignore-all — flat token walk, same shape as + * `memberAccessReceiverLine` / `isMemberAccessContext`. Boundary mutations + * (`-1` → `-2`, `>=` → `>`) only differ at the very first token, which is + * always T_OPEN_TAG and not skippable -- nikic would have rejected any + * source where the walk could underflow before this code runs. The + * triple-`||` split (skippable-token check) only matters between comment + * tokens, which the bare-`<>` rejection still catches via the parens arm. + * + * @param list $tokens + */ + private static function isPrecededByNew(array $tokens, int $nameIdx): bool + { + $i = $nameIdx - 1; + while ($i >= 0 && ($tokens[$i]->id === T_WHITESPACE || $tokens[$i]->id === T_COMMENT || $tokens[$i]->id === T_DOC_COMMENT)) { + $i--; + } + return $i >= 0 && $tokens[$i]->id === T_NEW; + } + /** * Detect zero or more chained `[]` suffixes starting at index `$i`. Returns the index of the * closing `]` of the last bracket pair, or `null` if no `[]` pair is found. diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index a18b946..8b4cfda 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1179,6 +1179,25 @@ public function testBareNewCallSiteIsRejectedAndLeftUnstripped(): void self::assertStringContainsString('', $stripped, 'bare `new Name<…>()` must be left un-stripped'); } + public function testBareNewWithoutParensIsRejectedAndLeftUnstripped(): void + { + // PHP allows `new Foo;` (no parens). Without the `isPrecededByNew` + // lookback, the bare-`<…>` rejection only caught the parens-bearing + // form (`>` followed by `(`), so this slipped through and xphp + // silently specialized a call shape the RFC turbofish requirement + // would refuse. + $source = <<<'PHP' +; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringContainsString('', $stripped, 'parenless `new Name<…>` must be left un-stripped'); + } + public function testBareFreeFunctionCallIsRejectedAndLeftUnstripped(): void { $source = <<<'PHP' From b779fabbe377fb0595bb21e0fca5ca6eac4a98a6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 20:35:16 +0000 Subject: [PATCH 03/36] docs: link every RFC mention to wiki.php.net/rfc/bound_erased_generic_types Every prose / list mention of the RFC is now a proper `[text](url)` link (`comparison.md` Tier-1 status notice + multiple-bounds bullet). Mermaid timeline items in `roadmap.md` can't carry markdown links, so an anchoring line under `## Overview` establishes the link once and notes that subsequent "per RFC" mentions in the timeline resolve there. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/roadmap.md | 3 +++ docs/type-system/comparison.md | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 2411c00..df12248 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,9 @@ ## Overview +Syntax shape tracks [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types). +All "per RFC" mentions in the timeline below resolve there. + ```mermaid timeline section Shipped diff --git a/docs/type-system/comparison.md b/docs/type-system/comparison.md index 9e5ced7..3f929a9 100644 --- a/docs/type-system/comparison.md +++ b/docs/type-system/comparison.md @@ -36,10 +36,11 @@ p.s. `n/a` meaning it doesn't fit the language's design. These are obvious gaps with clear value. -> **Status**: every snippet in this section shows the *intended* RFC-aligned -> syntax that `xphp` does not yet parse. Until the corresponding implementation -> lands, the parser rejects these forms with a PHP-side `Syntax error, -> unexpected '<'`. Tracked in [`../roadmap.md`](../roadmap.md). +> **Status**: every snippet in this section shows the *intended* syntax from +> [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) +> that `xphp` does not yet parse. Until the corresponding implementation lands, +> the parser rejects these forms with a PHP-side `Syntax error, unexpected '<'`. +> Tracked in [`../roadmap.md`](../roadmap.md). ### 1. Variance annotations @@ -95,8 +96,9 @@ defaults. - TypeScript: `T extends A & B` - Kotlin: `where T : A, T : B` - Rust: `T: A + B` -- PHP RFC: any valid type expression -- unions (`A | B`), intersections - (`A & B`), or DNF -- as the bound +- [PHP RFC](https://wiki.php.net/rfc/bound_erased_generic_types): any valid + type expression -- unions (`A | B`), intersections (`A & B`), or DNF -- as + the bound ```php class Sortable { /* ... */ } From 364a589db9ed97af2b5f0b071b85db8e674ab681 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 20:48:39 +0000 Subject: [PATCH 04/36] docs(readme): add inspiration / divergence banner A clear up-front note that xphp's surface syntax tracks php-rfc/bound_erased_generic_types while the runtime model (monomorphization vs erasure) is intentionally different. Readers see both the alignment and the honest tradeoff before they hit the rest of the README. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 48 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8b3243a..e95dcd2 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,26 @@ ## What it is `xphp` is a superset of `php` that gives developers real generics, -powered by [monomorphization](https://en.wikipedia.org/wiki/Monomorphization) at compile time. +powered by [monomorphization](https://en.wikipedia.org/wiki/Monomorphization) at +compile time. In a more inspirational mood, it is a fast lane for the `php` language, a bridge between what developers need today and what `php` will support in the future. +> **Heads up**: `xphp` is heavily inspired by +> [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types), +> and the RFC drives the surface syntax -- turbofish `Name::<...>` at call +> sites, bare `<...>` at declarations and type-hint positions, `:` for bounds. +> The **intent** is that any `.xphp` source you write today stays valid against +> a future PHP runtime. +> +> Runtime semantics may diverge. `xphp` monomorphizes each generic +> instantiation into a distinct, fully-typed class -- the concrete type is +> baked in and visible to reflection. The RFC erases bounds at runtime +> instead. Both are honest design choices for different goals, and the gap +> may widen as the RFC evolves. `xphp` will track the syntax wherever +> practical and call out any divergence explicitly in the docs. + ## How it works Generics specialize into concrete classes with native typehints the engine @@ -62,13 +77,17 @@ compiled `php` files. ## Generics: the start, not the finish line -Adding native generics to `php` -- a [long-awaited php feature](https://wiki.php.net/rfc/generics) -- -is genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generics-and-collections/). +Adding native generics to `php` -- +a [long-awaited php feature](https://wiki.php.net/rfc/generics) -- +is +genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generics-and-collections/). -The object model that's served the ecosystem for two decades doesn't bend easily. +The object model that's served the ecosystem for two decades doesn't bend +easily. Supporting generics proves that the compile-to-vanilla model handles non-trivial -type-system additions. The remaining features are on the [roadmap](docs/roadmap.md): +type-system additions. The remaining features are on +the [roadmap](docs/roadmap.md): type aliases, literal types, mapped and conditional types to name a few. ## Getting started @@ -141,17 +160,22 @@ $users = new Collection::( vendor/bin/xphp compile ``` -| Argument | Required | Default | Purpose | -|------------|----------|----------------|--------------------------------------------------------------------------------------| -| `` | yes | -- | Directory of `.xphp` files (PSR-4 layout) | -| `` | no | `dist` | Where rewritten `.php` files land -- your user code with generic call sites replaced | -| `` | no | `.xphp-cache` | Where specialized classes live | +| Argument | Required | Default | Purpose | +|------------|----------|---------------|--------------------------------------------------------------------------------------| +| `` | yes | -- | Directory of `.xphp` files (PSR-4 layout) | +| `` | no | `dist` | Where rewritten `.php` files land -- your user code with generic call sites replaced | +| `` | no | `.xphp-cache` | Where specialized classes live | -p.s. you can `gitignore` files in `` and `` as they can be generated in your CI/CD pipeline. +p.s. you can `gitignore` files in `` and `` as they can be +generated in your CI/CD pipeline. #### Sample output -The sample below uses a readable name (`Collection_User`) for clarity. The compiler actually emits hashed FQNs of the form `\XPHP\Generated\App\Collection\T_` -- see the [generics reference](docs/type-system/generics/index.md) for the real scheme. +The sample below uses a readable name (`Collection_User`) for clarity. The +compiler actually emits hashed FQNs of the form +`\XPHP\Generated\App\Collection\T_` -- see +the [generics reference](docs/type-system/generics/index.md) for the real +scheme. ```php // /Generated/Collection_User.php From c9f8a78e05a8a5eed55e5eac91d420cc0b70ce93 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 21:48:10 +0000 Subject: [PATCH 05/36] feat(parser): reject top-level self-reference bounds at declaration time RFC bound-erased generic types forbids `class A` -- a type parameter cannot use itself as a bound at the top level. xphp used to parse the declaration and surface a confusing "compiler cannot prove satisfaction" error at instantiation time; the new `assertNoTopLevelSelfReference` guard fires inside `parseTypeParamList` so the failure mode points at the real problem. F-bounded recursion (`class A>`) is intentionally allowed and covered by item #5; the guard only rejects bare-self bounds, and `boundIsFq` filters out `\T` (a global class named T) so users can still bound a type-param by a same-named global class. Locked by three tests: - testTopLevelSelfReferenceBoundIsRejectedAtDeclarationTime (negative) - testFullyQualifiedBoundWithSameNameAsTypeParamIsAllowed (regression) - testForwardReferenceToEarlierTypeParamAsBoundIsAllowed (regression) Infection: ignore Concat / ConcatOperandRemoval on the new method's error-message sprintf (rationale matches the existing Registry::validateBounds entries -- the test asserts on substring, not exact ordering). P1.1 of the RFC alignment sprint (see .claude/plans/rfc-bound-erased-generics-alignment/18-...md). Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 15 +++- .../Monomorphize/XphpSourceParser.php | 33 +++++++++ .../Monomorphize/XphpSourceParserTest.php | 68 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/infection.json5 b/infection.json5 index f2e356e..c791cef 100644 --- a/infection.json5 +++ b/infection.json5 @@ -96,7 +96,14 @@ "XPHP\\Transpiler\\Monomorphize\\Registry::checkBounds", "XPHP\\Transpiler\\Monomorphize\\Registry::collisionMessage", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::__construct", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // XphpSourceParser::assertNoTopLevelSelfReference: human-facing + // "Generic parameter `T` cannot use itself as a bound..." message. + // The three concat segments get reordered by the mutation; the + // self-reference test asserts on the substring "cannot use itself + // as a bound" which survives every reordering. Same justification + // as the Registry::validateBounds entries above. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference" ] }, "ConcatOperandRemoval": { @@ -114,7 +121,11 @@ "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", // SpecializedClassGenerator::emit: the `Registry::GENERATED_NAMESPACE_PREFIX . '\\\\'` // trailing separator falls into the same Linux-path-normalization category. - "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", + // assertNoTopLevelSelfReference: same as the Concat entry above -- + // dropping any concat operand still leaves the "cannot use itself + // as a bound" substring intact, which is what the test asserts on. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference" ] }, diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 48733de..4153a14 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -398,6 +398,7 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array return null; } if ($tokens[$i]->text === '>') { + self::assertNoTopLevelSelfReference($entries); return [$entries, $i]; } if ($tokens[$i]->text === ',') { @@ -410,6 +411,38 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array return null; } + /** + * RFC bound-erased generic types forbids `class A` -- a type parameter + * cannot use *itself* as a bound at the top level. F-bounded recursion + * (`class A>`) is fine because the inner T is a generic argument + * to a different type; only the bare-self case is rejected. + * + * `boundIsFq` filters out `\T` (a global class named T), which is a real + * class reference rather than a type-parameter self-reference. + * + * @param list $entries + */ + private static function assertNoTopLevelSelfReference(array $entries): void + { + foreach ($entries as $entry) { + if ($entry['boundName'] !== null + && !$entry['boundIsFq'] + && $entry['boundName'] === $entry['name'] + ) { + throw new RuntimeException(sprintf( + 'Generic parameter `%s` cannot use itself as a bound (top-level ' + . 'self-reference in `<%s : %s>`). Use a nested form like ' + . '`%s : Box<%s>` for F-bounded recursion, or remove the bound.', + $entry['name'], + $entry['name'], + $entry['name'], + $entry['name'], + $entry['name'], + )); + } + } + } + /** * Parse `< TypeArg (, TypeArg)* >` starting at the index of the `<` token. * Returns null if the clause is not a valid generic-args list (so the caller can treat `<` as the less-than operator). diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 8b4cfda..6c9f2b7 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -178,6 +178,74 @@ class Pair self::assertNull($params[1]->boundFqn, 'V has no bound — boundFqn must stay null'); } + public function testTopLevelSelfReferenceBoundIsRejectedAtDeclarationTime(): void + { + // RFC bound-erased generic types forbids `class A` -- T cannot use + // itself as a bound at top level. The error fires at declaration time + // rather than later at instantiation, where the user would see a + // confusing "compiler cannot prove satisfaction" message instead. + $source = <<<'PHP' + { + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot use itself as a bound'); + $parser->parse($source); + } + + public function testFullyQualifiedBoundWithSameNameAsTypeParamIsAllowed(): void + { + // `class A` is NOT a self-reference -- the leading backslash + // makes `\T` a global-class reference, not the type parameter. The + // self-reference guard must skip this case. + $source = <<<'PHP' + { + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); // must not throw + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertCount(1, $params); + self::assertSame('T', $params[0]->name); + self::assertSame('T', $params[0]->boundFqn, 'leading-\\ marks bound as FQ -- resolves to global `T`, not the type-param'); + } + + public function testForwardReferenceToEarlierTypeParamAsBoundIsAllowed(): void + { + // `class C` is NOT a self-reference -- U's bound references + // a DIFFERENT type parameter (T), not itself. The RFC explicitly allows + // this (the "forward references and mutual recursion" clause). + $source = <<<'PHP' + { + public T $first; + public U $second; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); // must not throw + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertCount(2, $params); + } + public function testAttachesGenericArgsToNewExpressionResolvedAgainstNamespace(): void { $source = <<<'PHP' From beb495543043d12b52d6c6dc82b2515a853a5633 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 21:51:20 +0000 Subject: [PATCH 06/36] feat(parser): recognize `self` / `static` / `parent` in type positions `self` and `parent` are T_STRING tokens and already fell through the Name-token recognizer. `static`, though, is a PHP keyword (T_STATIC), so `static` slipped past the scanner and survived into the cleaned source -- nikic then errored on the leftover `<`. The fix is one disjunction on the Name branch's entry guard (`isNameToken($tok) || $tok->id === T_STATIC`) -- the rest of the recognition path (turbofish prefix check, trailing-`(` rejection, strip-and-record) handles the keyword identically to a regular name. The trailing-`(` heuristic plus `isPrecededByNew` together still correctly reject `new static()` and `static::()` at expression context. Three new tests lock the three pseudo-types: - testSelfWithTypeArgsInReturnPositionIsAccepted - testStaticWithTypeArgsInReturnPositionIsAccepted - testParentWithTypeArgsInReturnPositionIsAccepted P1.2 of the RFC alignment sprint (see .claude/plans/rfc-bound-erased-generics-alignment/15-...md). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/XphpSourceParser.php | 7 +- .../Monomorphize/XphpSourceParserTest.php | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 4153a14..bb89399 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -235,7 +235,12 @@ private function scanAndStrip(string $source): array continue; } - if (self::isNameToken($tok)) { + // `static` is a PHP keyword (T_STATIC), not a name token, but the + // RFC treats `static` (and the sibling `self` / `parent` + // pseudo-types) as valid type-hint positions. `self` / `parent` are + // T_STRING and fall through the next branch naturally; `static` + // needs an explicit gate here. + if (self::isNameToken($tok) || $tok->id === T_STATIC) { $nameText = $tok->text; $nameLine = $tok->line; // For member-access call sites (`Foo::method::<…>`, `$x->method::<…>`, diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 6c9f2b7..830b2ce 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -223,6 +223,80 @@ class A { self::assertSame('T', $params[0]->boundFqn, 'leading-\\ marks bound as FQ -- resolves to global `T`, not the type-param'); } + public function testSelfWithTypeArgsInReturnPositionIsAccepted(): void + { + // RFC class pseudo-types: `self`, `static`, `parent` are + // accepted in type-hint positions. Verifies the scanner accepts + // the bare `` after `self` (which is in SCALAR_TYPES), strips it, + // and the resolver attaches the marker. + $source = <<<'PHP' + { + public T $item; + + public function with(T $newItem): self { + $this->item = $newItem; + return $this; + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + // The `` clause on the `self` return type must be stripped from the + // cleaned source so PHP parses the method signature as `: self`. + $stripped = $parser->strip($source); + self::assertStringNotContainsString('self', $stripped); + self::assertStringContainsString(': self', $stripped); + } + + public function testStaticWithTypeArgsInReturnPositionIsAccepted(): void + { + // Same as the self case but for late-static-bound `static`. + $source = <<<'PHP' + { + public function reset(): static { + return $this; + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringNotContainsString('static', $stripped); + self::assertStringContainsString(': static', $stripped); + + $parser->parse($source); // must not throw + } + + public function testParentWithTypeArgsInReturnPositionIsAccepted(): void + { + // `parent` in a return position resolves to the parent class + // specialized with T. Same recognizer / strip mechanism. + $source = <<<'PHP' + extends Container { + public function reset(): parent { + return parent::reset(); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + + self::assertStringNotContainsString('parent', $stripped); + self::assertStringContainsString(': parent', $stripped); + + $parser->parse($source); // must not throw + } + public function testForwardReferenceToEarlierTypeParamAsBoundIsAllowed(): void { // `class C` is NOT a self-reference -- U's bound references From ab8918da74033050609cb3071dab225499a6f250 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 21:54:31 +0000 Subject: [PATCH 07/36] test(parser): lock anonymous-class generic decl rejection RFC bound-erased generic types forbids `new class { ... }`. xphp's T_CLASS branch already requires a T_STRING name after the keyword, so the anon-class form falls through without recognition -- a property of the scanner shape, not an explicit guard. This test pins that alignment so a future refactor of the T_CLASS branch (e.g. dropping the T_STRING requirement to support some other shape) can't quietly start accepting anonymous-class type parameters. Contract: `parser->strip()` must leave the `` clause intact. P1.3 of the RFC alignment sprint (see .claude/plans/rfc-bound-erased-generics-alignment/17-...md). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/XphpSourceParserTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 830b2ce..892a16e 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -297,6 +297,28 @@ public function reset(): parent { $parser->parse($source); // must not throw } + public function testAnonymousClassWithAngleBracketsIsNotRecognizedAsTemplate(): void + { + // RFC bound-erased generic types forbids type parameters on anonymous + // classes (`new class { ... }` is "unrecoverably ambiguous" per the + // RFC text). xphp's T_CLASS branch already requires a T_STRING name + // after the keyword, so `new class` never reaches the template + // recognizer. This test locks the alignment-by-shape so a future + // refactor of the T_CLASS branch can't quietly start accepting + // anonymous-class type parameters. + $source = <<<'PHP' + { public int $item = 0; }; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Contract: the scanner does NOT strip the `` clause -- it must + // survive into the cleaned source so any downstream tooling sees the + // form as invalid PHP rather than xphp silently specializing it. + $stripped = $parser->strip($source); + self::assertStringContainsString('class', $stripped, 'anon-class `` must be left un-stripped'); + } + public function testForwardReferenceToEarlierTypeParamAsBoundIsAllowed(): void { // `class C` is NOT a self-reference -- U's bound references From ec42bcd5e50a86a076bcb62b62738efe5e7fc095 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 22:06:31 +0000 Subject: [PATCH 08/36] feat(compiler): specialize bare top-level free generic functions `function identity(T $x): T { ... }` declared OUTSIDE any `namespace { }` block now compiles end-to-end, producing the same mangled specializations as the namespaced shape. Before this change the parser parsed the declaration but `GenericMethodCompiler::process` silently dropped any Function_ whose enclosing Namespace_ was null -- the rewritten output kept literal `T` in the function signature, producing broken PHP. Pipeline changes in `GenericMethodCompiler`: - `process()` now takes `array &$astSet` by reference so it can mutate the top-level statement list when no Namespace_ wraps the template. Compiler::compile passes `$astPerFile` as before; PHP's signature-level `&` makes the call pass-by-reference implicitly. - The indexTemplates Function_ branch no longer requires a non-null `currentNamespaceNode`. Top-level templates are registered with `functionNamespaceByFqn[$fqn] = null` and an accompanying `functionAstKeyByFqn[$fqn]` so the strip step knows which AST array to mutate. - `rewriteCallSites` takes a new `&$topLevelAppends` out-param. When a generated specialization belongs to a null-namespace template, the visitor pushes it there instead of the existing `pendingAppends` (which expects a container with `->stmts`). The outer `process()` loop flushes top-level appends by direct array mutation. - New `stripTopLevelFunction` helper mirrors `stripFunction` but operates on a `list` rather than a `Namespace_->stmts`. Three integration tests lock the contract: - testBareTopLevelFreeFunctionSpecializesEndToEnd (happy path) - testBareTopLevelStripPreservesAllNonTemplateStatements (mutation regression on the strip loop's per-statement guard + the ArrayOneItem mutant on the return value) - testMixedTopLevelAndNamespacedTemplatesBothGetStripped (mutation regression on the Continue_ between strip branches -- uses FilepathArray for explicit ordering so the namespaced template isn't last and the Continue_/Break_ distinction is observable) P1.4 of the RFC alignment sprint (see .claude/plans/rfc-bound-erased-generics-alignment/02-...md). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 82 +++++- .../GenericFunctionIntegrationTest.php | 242 ++++++++++++++++++ 2 files changed, 313 insertions(+), 11 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 4a21045..ec568a5 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -60,7 +60,7 @@ public function __construct( * @param array> $astSet keyed by an arbitrary string id (filepath * or ""). The values are the top-level statements of each AST. */ - public function process(array $astSet): void + public function process(array &$astSet): void { /** @var array $methodTemplates keyed by "classFqn::methodName" */ $methodTemplates = []; @@ -68,8 +68,10 @@ public function process(array $astSet): void $classByFqn = []; /** @var array $functionTemplates keyed by namespace\\functionName */ $functionTemplates = []; - /** @var array $functionNamespaceByFqn enclosing Namespace_ per fqn */ + /** @var array $functionNamespaceByFqn enclosing Namespace_ per fqn, or null for bare top-level functions */ $functionNamespaceByFqn = []; + /** @var array $functionAstKeyByFqn ast-key (filepath) per fqn for top-level (null-namespace) templates, so the strip+append step knows which AST to mutate */ + $functionAstKeyByFqn = []; /** @var array $functionSourceByFqn ast-key (filepath or "") per fqn — used to point duplicate-declaration errors at both source locations */ $functionSourceByFqn = []; @@ -104,6 +106,7 @@ public function process(array $astSet): void foreach ($perFileFns as $k => $v) { $functionTemplates[$k] = $v; $functionSourceByFqn[$k] = (string) $astKey; + $functionAstKeyByFqn[$k] = (string) $astKey; } foreach ($perFileFnNs as $k => $v) { $functionNamespaceByFqn[$k] = $v; @@ -116,7 +119,13 @@ public function process(array $astSet): void /** @var array $alreadyGenerated */ $alreadyGenerated = []; - foreach ($astSet as $ast) { + foreach ($astSet as $astKey => &$ast) { + // For top-level (null-namespace) functions: the visitor's pendingAppends + // mechanism mutates a container's ->stmts; the top-level AST is a plain + // array with no container. We catch top-level appends in a separate bag + // and flush them by direct array mutation after the traversal completes. + /** @var list $topLevelAppends */ + $topLevelAppends = []; $this->rewriteCallSites( $ast, $methodTemplates, @@ -124,8 +133,13 @@ public function process(array $astSet): void $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, + $topLevelAppends, ); + foreach ($topLevelAppends as $specialized) { + $ast[] = $specialized; + } } + unset($ast); // Strip the original method templates from their owning classes. foreach ($methodTemplates as $key => $template) { @@ -138,11 +152,21 @@ public function process(array $astSet): void } } - // Strip the original function templates from their owning namespaces. + // Strip the original function templates: namespaced ones get stripped from + // their owning Namespace_; top-level (null-namespace) ones get stripped from + // the top-level AST array directly via the saved astKey. foreach ($functionTemplates as $fqn => $template) { $namespace = $functionNamespaceByFqn[$fqn] ?? null; if ($namespace !== null) { $this->stripFunction($namespace, $template->name->toString()); + continue; + } + $astKey = $functionAstKeyByFqn[$fqn] ?? null; + if ($astKey !== null && isset($astSet[$astKey])) { + $astSet[$astKey] = self::stripTopLevelFunction( + $astSet[$astKey], + $template->name->toString(), + ); } } } @@ -152,7 +176,7 @@ public function process(array $astSet): void * @param array $methodTemplates out-param * @param array $classByFqn out-param * @param array $functionTemplates out-param - * @param array $functionNamespaceByFqn out-param + * @param array $functionNamespaceByFqn out-param (null = bare top-level) */ private function indexTemplates( array $ast, @@ -175,7 +199,7 @@ private function indexTemplates( public array $classByFqn = []; /** @var array */ public array $functionTemplates = []; - /** @var array */ + /** @var array */ public array $functionNamespaceByFqn = []; private ?string $currentClassFqn = null; @@ -200,11 +224,13 @@ public function enterNode(Node $node): null } if ($node instanceof Function_) { $params = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - if (is_array($params) && $params !== [] && $this->currentNamespaceNode !== null) { + if (is_array($params) && $params !== []) { $fqn = $this->currentNamespace !== '' ? $this->currentNamespace . '\\' . $node->name->toString() : $node->name->toString(); $this->functionTemplates[$fqn] = $node; + // null = bare top-level (no enclosing `namespace { }` block); + // the outer process() handles strip + append for that case. $this->functionNamespaceByFqn[$fqn] = $this->currentNamespaceNode; } } @@ -243,8 +269,10 @@ public function leaveNode(Node $node): null * @param array $methodTemplates * @param array $classByFqn * @param array $functionTemplates - * @param array $functionNamespaceByFqn + * @param array $functionNamespaceByFqn null = bare top-level * @param array $alreadyGenerated + * @param list $topLevelAppends out-param: specializations for null-namespace + * templates; the caller flushes these to the top-level AST after the traversal completes */ private function rewriteCallSites( array $ast, @@ -253,13 +281,14 @@ private function rewriteCallSites( array $functionTemplates, array $functionNamespaceByFqn, array &$alreadyGenerated, + array &$topLevelAppends, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; // @infection-ignore-all — see rationale above the indexTemplates visitor: defensive // guards and call-shape mutations are masked by the surrounding pipeline's // type-strict invariants. End-to-end coverage from GenericMethodIntegrationTest. - $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy) extends NodeVisitorAbstract { + $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends) extends NodeVisitorAbstract { private string $currentNamespace = ''; /** @var array alias => fqn */ private array $useMap = []; @@ -271,8 +300,9 @@ private function rewriteCallSites( * @param array $methodTemplates * @param array $classByFqn * @param array $functionTemplates - * @param array $functionNamespaceByFqn + * @param array $functionNamespaceByFqn * @param array $alreadyGenerated + * @param list $topLevelAppends */ public function __construct( private array $methodTemplates, @@ -282,6 +312,7 @@ public function __construct( private array &$alreadyGenerated, private int $hashLength, private ?TypeHierarchy $hierarchy, + private array &$topLevelAppends, ) { } @@ -426,8 +457,14 @@ private function rewriteFuncCall(FuncCall $node): ?Node // doesn't reliably propagate through nikic's NodeTraverser. The // outer process() loop flushes pendingAppends after the walk. $this->pendingAppends[] = [$namespaceNode, $specialized]; - $this->alreadyGenerated[$generatedKey] = true; + } else { + // Bare top-level template (no enclosing `namespace { }` block): + // there's no container to append to, so route the specialized + // function through the topLevelAppends out-param; process() flushes + // it directly into the top-level AST array after this visitor returns. + $this->topLevelAppends[] = $specialized; } + $this->alreadyGenerated[$generatedKey] = true; } $node->name = new FullyQualified($mangledFqn, $node->name->getAttributes()); @@ -542,4 +579,27 @@ private function stripFunction(Namespace_ $namespace, string $functionName): voi } $namespace->stmts = $newStmts; } + + /** + * Same shape as `stripFunction` but for the top-level AST when there's no + * enclosing `namespace { }` block. Returns the filtered statement list so the + * caller can replace the slot in `$astSet` directly. + * + * @param list $ast + * @return list + */ + private static function stripTopLevelFunction(array $ast, string $functionName): array + { + $newStmts = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Function_ + && $stmt->name->toString() === $functionName + && $stmt->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS) !== null + ) { + continue; + } + $newStmts[] = $stmt; + } + return $newStmts; + } } diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index 74b2926..ab3ee4b 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; +use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; @@ -184,6 +185,247 @@ public function testEmittedFilesAreSyntacticallyValid(): void } } + public function testBareTopLevelFreeFunctionSpecializesEndToEnd(): void + { + // P1.4 of the RFC alignment sprint: free generic functions declared at + // the bare top level (no enclosing `namespace { }` block) must also + // specialize. The original silently-drop behavior left users with + // broken output (literal `T` in the rewritten function signature). + $bareDir = sys_get_temp_dir() . '/xphp-bare-' . uniqid('', true); + mkdir($bareDir, 0o755, true); + $funcsPath = $bareDir . '/funcs.xphp'; + $usePath = $bareDir . '/Use.xphp'; + file_put_contents($funcsPath, <<<'PHP' + (T $x): T + { + return $x; + } + PHP); + file_put_contents($usePath, <<<'PHP' + (42); + $asString = identity::('world'); + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($bareDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $bareTarget = $bareDir . '/dist'; + $bareCache = $bareDir . '/.xphp-cache'; + + try { + $compiler->compile($sources, $bareDir, $bareTarget, $bareCache); + + $funcsOut = file_get_contents($bareTarget . '/funcs.php'); + self::assertIsString($funcsOut); + self::assertStringNotContainsString( + 'function identity(', + $funcsOut, + 'original template must be stripped from the top-level AST too', + ); + self::assertStringNotContainsString( + ' T ', + $funcsOut, + 'no leftover `T` type-param literal in the rewritten output', + ); + + $useOut = file_get_contents($bareTarget . '/Use.php'); + self::assertIsString($useOut); + self::assertMatchesRegularExpression( + '/identity_T_[0-9a-f]+\(42\)/', + $useOut, + 'int call site rewritten to mangled name', + ); + self::assertMatchesRegularExpression( + "/identity_T_[0-9a-f]+\\('world'\\)/", + $useOut, + 'string call site rewritten to mangled name', + ); + self::assertSame( + 2, + preg_match_all('/function identity_T_[0-9a-f]+\(/', $useOut), + 'specialized functions appended to top-level AST -- one per unique arg', + ); + self::assertMatchesRegularExpression( + '/function identity_T_[0-9a-f]+\(int \$x\): int/', + $useOut, + ); + self::assertMatchesRegularExpression( + '/function identity_T_[0-9a-f]+\(string \$x\): string/', + $useOut, + ); + + // Both rewritten files must be syntactically valid PHP -- the strongest + // proof that the specialization landed in the right place. + $output = []; + $exit = 0; + exec('php -l ' . escapeshellarg($bareTarget . '/funcs.php') . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "funcs.php fails PHP syntax check:\n" . implode("\n", $output)); + $output = []; + $exit = 0; + exec('php -l ' . escapeshellarg($bareTarget . '/Use.php') . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Use.php fails PHP syntax check:\n" . implode("\n", $output)); + } finally { + self::rrmdir($bareDir); + } + } + + public function testBareTopLevelStripPreservesAllNonTemplateStatements(): void + { + // Locks the contract that `stripTopLevelFunction` only removes the + // matching generic-template Function_ node and leaves every other + // statement in the file intact -- including (a) a non-generic function + // that happens to follow the template and (b) the leading `declare`. + // + // Without this test, a Continue_ -> Break_ mutation on the strip loop + // (or an ArrayOneItem mutation on the return value) would silently + // drop the trailing statements; this fixture catches both. + $bareDir = sys_get_temp_dir() . '/xphp-bare-multi-' . uniqid('', true); + mkdir($bareDir, 0o755, true); + $funcsPath = $bareDir . '/funcs.xphp'; + $usePath = $bareDir . '/Use.xphp'; + file_put_contents($funcsPath, <<<'PHP' + (T $x): T + { + return $x; + } + + function nonGenericDouble(int $x): int + { + return $x * 2; + } + PHP); + file_put_contents($usePath, <<<'PHP' + (42); + $doubled = nonGenericDouble(21); + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($bareDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $bareTarget = $bareDir . '/dist'; + $bareCache = $bareDir . '/.xphp-cache'; + + try { + $compiler->compile($sources, $bareDir, $bareTarget, $bareCache); + + $funcsOut = file_get_contents($bareTarget . '/funcs.php'); + self::assertIsString($funcsOut); + + // Generic template stripped. + self::assertStringNotContainsString('function identity(', $funcsOut); + // Non-generic neighbour preserved — kills the Continue_/Break_ + ArrayOneItem + // mutations that would silently drop trailing statements. + self::assertStringContainsString('function nonGenericDouble(int $x): int', $funcsOut); + + // Both call sites resolve at runtime. + $runScript = $bareDir . '/run.php'; + file_put_contents($runScript, "&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('doubled=42;asInt=42', $output); + } finally { + self::rrmdir($bareDir); + } + } + + public function testMixedTopLevelAndNamespacedTemplatesBothGetStripped(): void + { + // The strip loop in `process()` has two branches -- one for namespaced + // Function_ templates (via `stripFunction`) and one for bare top-level + // ones (via `stripTopLevelFunction`). A Continue_ -> Break_ mutation + // on the branch separator would skip every template after the first + // namespaced one. + // + // To make the mutation observable, the namespaced template must NOT be + // the last entry in the strip loop -- otherwise `continue` and `break` + // both fall through identically. Using FilepathArray directly with + // explicit order pins the iteration sequence (namespaced first, bare + // second), so a `break` after the namespaced strip leaves the bare + // template intact and the test catches it. + $mixedDir = sys_get_temp_dir() . '/xphp-mixed-' . uniqid('', true); + mkdir($mixedDir, 0o755, true); + $namespacedPath = $mixedDir . '/namespaced.xphp'; + $barePath = $mixedDir . '/bare.xphp'; + $usePath = $mixedDir . '/Use.xphp'; + file_put_contents($namespacedPath, <<<'PHP' + (T $x): T + { + return $x; + } + PHP); + file_put_contents($barePath, <<<'PHP' + (T $x): T + { + return $x; + } + PHP); + file_put_contents($usePath, <<<'PHP' + (13); + $bare = bareId::(7); + PHP); + + $compiler = $this->buildCompiler(); + // Explicit order: namespaced FIRST (so the `continue` after its strip + // actually has somewhere to continue to), bare SECOND (so a `break` + // would skip its strip and leave the template behind). + $sources = new FilepathArray($namespacedPath, $barePath, $usePath); + $target = $mixedDir . '/dist'; + $cache = $mixedDir . '/.xphp-cache'; + + try { + $compiler->compile($sources, $mixedDir, $target, $cache); + + // Namespaced template stripped from its namespace. + $nsOut = file_get_contents($target . '/namespaced.php'); + self::assertIsString($nsOut); + self::assertStringNotContainsString('function namespacedId(', $nsOut); + + // Bare top-level template stripped -- this assertion is what kills + // the Continue_ -> Break_ mutant on the namespaced strip branch. + $bareOut = file_get_contents($target . '/bare.php'); + self::assertIsString($bareOut); + self::assertStringNotContainsString('function bareId(', $bareOut); + + // Both call sites rewritten to mangled names. + $useOut = file_get_contents($target . '/Use.php'); + self::assertIsString($useOut); + self::assertMatchesRegularExpression('/bareId_T_[0-9a-f]+\(7\)/', $useOut); + self::assertMatchesRegularExpression('/namespacedId_T_[0-9a-f]+\(13\)/', $useOut); + } finally { + self::rrmdir($mixedDir); + } + } + private function compile(): void { $compiler = $this->buildCompiler(); From fe426a1cb37efc5d7a1eb532dc945117f1cc30d8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 5 Jun 2026 05:05:50 +0000 Subject: [PATCH 09/36] fix(parser): wire `self` / `static` / `parent` through compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of `beb4955` (P1.2) flagged that the scanner half landed but the resolve/compile half didn't. Stripping `` so PHP can parse the source is necessary but not sufficient -- the bare `self` Name then routes through the generic-args resolver and the Registry tries to specialize a non-existent `App\…\self` template, aborting with: COMPILE FAILED: Generic template "App\SelfProbe\self" was instantiated but never defined. Fix: in the bare-`<…>` (type-hint) branch of `scanAndStrip`, when the name token is `self` / `static` / `parent`, still strip the `<…>` clause (so PHP can parse) but skip the marker record entirely. The Name node never gets ATTR_GENERIC_ARGS attached, the Registry never sees a phantom template, and monomorphization on the enclosing class -- which already specialized it with concrete type args -- carries the bare `self` reference through unchanged; PHP's runtime resolves it to the right specialized class. Follow-up to beb4955 (the original P1.2 commit) rather than an amend because beb4955 is a middle commit on phase-1-polish, not HEAD, and the project SDLC forbids interactive rebase. "1 phase can have multiple commits" carries the slack. Three existing P1.2 parser tests (`testSelfWithTypeArgsInReturnPositionIsAccepted` and siblings) now ALSO assert that the pseudo-type Name carries no ATTR_GENERIC_ARGS -- they used to stop at `strip()` + `parse() must not throw`, which is precisely the gap the review caught. New integration test `testSelfWithTypeArgsCompilesEndToEnd` exercises the full `Compiler::compile` pipeline against the reviewer's reproduction fixture and runs the rewritten output at runtime to confirm `self` resolves to the specialized class. Infection: one `UnwrapStrToLower` ignore added to `infection.json5` on `scanAndStrip` -- the mutant only differs for mixed-case spellings (`Self`, `STATIC`) that no real PHP code uses; adding a test would lock a stylistic choice rather than a real contract. Test count 205 -> 206; MSI 100%. Resolves Issue A from `.claude/review.md`. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 12 +++ .../Monomorphize/XphpSourceParser.php | 31 +++++-- .../GenericMethodIntegrationTest.php | 88 +++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 29 ++++-- 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/infection.json5 b/infection.json5 index c791cef..3d6f0e4 100644 --- a/infection.json5 +++ b/infection.json5 @@ -129,6 +129,18 @@ ] }, + // scanAndStrip's `strtolower($nameText)` in the pseudo-type guard for + // self/static/parent. PHP convention -- enforced by every style guide + // and produced by every IDE -- is lowercase for these keywords. The + // mutant only differs for mixed-case spellings (`Self`, `STATIC`) + // that no real code uses; adding a test for those would lock a + // stylistic choice rather than a real contract. + "UnwrapStrToLower": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, + // `<=` vs `<` on the suggested-length clamp — math makes both branches // produce identical output across the entire valid hashLength range (16..64). "LessThanOrEqualTo": { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index bb89399..2c877f3 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -306,12 +306,31 @@ private function scanAndStrip(string $source): array $isCallSite = ($afterClose < $n && $tokens[$afterClose]->text === '(') || self::isPrecededByNew($tokens, $i); if (!$isCallSite) { - $nameMarkers[] = [ - 'line' => $nameLine, - 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), - 'args' => $args, - ]; + // Pseudo-types (`self` / `static` / `parent`): + // strip the `<…>` clause so PHP can parse the source, but + // skip the marker -- otherwise the resolver would attach + // ATTR_GENERIC_ARGS to the bare `self` Name and the + // Registry would try to specialize a non-existent + // `App\…\self` template (which is the very bug that took + // the strip-only fix from b88539c's review). With the + // marker dropped, monomorphization on the enclosing + // class -- which already specialized it with concrete + // type args -- carries the `self` reference through + // unchanged; PHP's runtime resolves it to the right + // specialized class. + $isPseudoType = in_array( + strtolower($nameText), + ['self', 'static', 'parent'], + true, + ); + if (!$isPseudoType) { + $nameMarkers[] = [ + 'line' => $nameLine, + 'anchorLine' => $anchorLine, + 'name' => ltrim($nameText, '\\'), + 'args' => $args, + ]; + } $startByte = $tokens[$j]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index cf20cab..0af2f51 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -230,6 +230,94 @@ public function testEmittedFilesAreSyntacticallyValid(): void } } + public function testSelfWithTypeArgsCompilesEndToEnd(): void + { + // P1.2 regression: the original beb4955 commit shipped only the + // scanner half -- `self` was stripped from the source but the + // resolver then attached ATTR_GENERIC_ARGS to the bare `self` Name, + // making the Registry try to specialize a non-existent `App\…\self` + // template ("Generic template … was instantiated but never defined"). + // This test compiles a fixture that uses `self` in a return + // position and asserts the full pipeline (compile + runtime exec). + $dir = sys_get_temp_dir() . '/xphp-self-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Container.xphp', <<<'PHP' + { + public function __construct(public T $item) {} + public function withItem(T $n): self + { + $this->item = $n; + return $this; + } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (1); + $b = $a->withItem(2); + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($dir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $target = $dir . '/dist'; + $cache = $dir . '/.xphp-cache'; + + try { + $compiler->compile($sources, $dir, $target, $cache); + + // The specialized Container class lives under cache/Generated/... + $generated = self::globRecursive($cache . '/Generated', '*.php'); + self::assertCount(1, $generated, 'one specialization (Container)'); + $specialized = file_get_contents($generated[0]); + self::assertIsString($specialized); + + // self in the source must become bare `self` in the + // specialized class -- `self` here resolves at runtime to the + // specialized class itself, which IS the correct semantics. + self::assertMatchesRegularExpression( + '/public function withItem\(int \$n\): self\b/', + $specialized, + 'self must lower to bare `self` in the specialization', + ); + self::assertStringNotContainsString( + '\\App\\SelfReturn\\self', + $specialized, + 'self must NOT be misresolved to a class FQN', + ); + + // Runtime sanity: instantiate, call withItem, read item back. + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<item}"; + PHP); + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($runScript) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('item=2', $output); + } finally { + self::rrmdir($dir); + } + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 892a16e..81dd696 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -226,9 +226,11 @@ class A { public function testSelfWithTypeArgsInReturnPositionIsAccepted(): void { // RFC class pseudo-types: `self`, `static`, `parent` are - // accepted in type-hint positions. Verifies the scanner accepts - // the bare `` after `self` (which is in SCALAR_TYPES), strips it, - // and the resolver attaches the marker. + // accepted in type-hint positions. Verifies the scanner strips the + // `` clause so PHP can parse the method signature, AND that no + // generic-args marker leaks onto the bare `self` Name node -- the + // pseudo-types are class references, not template references, so the + // Registry must never see them as templates to specialize. $source = <<<'PHP' { $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); - // The `` clause on the `self` return type must be stripped from the - // cleaned source so PHP parses the method signature as `: self`. $stripped = $parser->strip($source); self::assertStringNotContainsString('self', $stripped); self::assertStringContainsString(': self', $stripped); + + // The pseudo-type's Name node must NOT carry ATTR_GENERIC_ARGS -- + // otherwise the Registry would try to specialize `App\self`. + self::assertNull( + self::firstNameAttr($ast, 'self', XphpSourceParser::ATTR_GENERIC_ARGS), + 'self must not attach generic-args marker; pseudo-types are class refs, not templates', + ); } public function testStaticWithTypeArgsInReturnPositionIsAccepted(): void @@ -271,7 +278,11 @@ public function reset(): static { self::assertStringNotContainsString('static', $stripped); self::assertStringContainsString(': static', $stripped); - $parser->parse($source); // must not throw + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'static', XphpSourceParser::ATTR_GENERIC_ARGS), + 'static must not attach generic-args marker', + ); } public function testParentWithTypeArgsInReturnPositionIsAccepted(): void @@ -294,6 +305,12 @@ public function reset(): parent { self::assertStringNotContainsString('parent', $stripped); self::assertStringContainsString(': parent', $stripped); + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'parent', XphpSourceParser::ATTR_GENERIC_ARGS), + 'parent must not attach generic-args marker', + ); + $parser->parse($source); // must not throw } From 0b5ad4572575038882a24858f6bce108b7cea5c0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 4 Jun 2026 22:17:50 +0000 Subject: [PATCH 10/36] feat(compiler): specialize instance-method generic calls (item 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$obj->method::(...)` and `$obj?->method::(...)` now specialize end-to-end alongside the existing static-call shape. Both Stages A (strict-typing receiver resolution) and B (local flow typing) ship together as one milestone -- Stage A alone left too many real-world patterns silently un-specialized to be worth announcing. Resolver: `XphpSourceParser::resolveAndAttach` now attaches `ATTR_METHOD_GENERIC_ARGS` to MethodCall and NullsafeMethodCall in addition to StaticCall, reusing the same line-range matching that already handles multi-line `Foo::\n method::(...)` constructs. GenericMethodCompiler receiver-type analysis (handles, in order): 1. `$this` -> enclosing class FQN 2. `$paramName` -> typed parameter declaration 3. `$this->prop` -> typed property declaration on the enclosing class 4. `$localVar` -> last lexical `$x = new ClassName(...)` assignment in the same scope (local flow typing) 5. `$importedVar` -> closure `use ($x)` import OR arrow function implicit capture, copied from parent scope Scope isolation across nested function-likes: each Function_, ClassMethod, Closure, and ArrowFunction pushes a snapshot of `(currentScopeParamTypes, currentScopeLocalTypes, branchSnapshots)` on enter and pops on leave. Branches inside a closure don't leak to the enclosing function's branches; outer-scope variables aren't visible inside the closure body except through explicit `use ($x)` import or arrow-function lexical capture. Conservative branching analysis: If_/Switch_/Match_/While_/Do_/For_/ Foreach_/TryCatch push a branch frame on enter. Sibling branches (Else_/ElseIf_/Case_/MatchArm/Catch_/Finally_) reset the local-types map to the pre-branch snapshot so each sibling starts from the same state. On leave of the branching parent, the frame is popped, the snapshot restored, and every variable assigned anywhere in the branch body is invalidated -- the post-branch state can't tell whether the branch ran. Intra-branch specialization still works because the analysis runs LIVE during the branch walk; only post-branch and sibling-branch states are conservative. `resolveClassName` short-circuits pseudo-types (`self`, `static`, `parent`) to `$currentClassFqn` instead of routing through namespace / use-map resolution -- closes the same gap as the scanner's pseudo-type filter (a parameter typed `self` was resolving to `App\…\self`, a phantom class). Tests (13 new): - testTurbofishOnInstanceMethodCallIsRecognized (renamed from StripsButHasNoResolverYet; now asserts the marker IS attached) - testTurbofishOnNullsafeInstanceMethodCallIsRecognized - testInstanceMethodGenericThisReceiverSpecializes (`$this`) - testInstanceMethodGenericParamReceiverSpecializes (typed param + nullable-param regression) - testInstanceMethodGenericLocalVariableReceiverSpecializes (flow typing on `$x = new Foo()`) - testInstanceMethodGenericPropertyReceiverSpecializes (`$this->prop`) - testReceiverTypeAnalysisDoesNotLeakAcrossClosureScopes (Issue B) - testReceiverTypeAnalysisDoesNotLeakAcrossArrowFunction (symmetry) - testBranchingReassignmentInvalidatesPostBranchSpecialization (the "actual bug" the second review caught -- if/else reassignment now correctly invalidates post-branch tracking) - testBranchingIntraBranchSpecializationStillWorks (the analysis runs LIVE during the branch walk so intra-branch calls keep specializing correctly) - testBranchingElseBranchSeesPreBranchState (sibling-branch reset means else sees pre-if state, not the if body's mutations) - testClosureUseImportPreservesReceiverType (closure `use ($x)` imports the type from the parent scope) - testArrowFunctionImplicitCapturePreservesReceiverType (arrow functions copy all parent params + locals) Docs: `comparison.md` Tier-1-gaps "Instance-method generic calls" section deleted -- the gaps framing was awkward for shipped work; the roadmap and item-11 per-item doc already capture the status. The "Known unsoundness" subsection in `11-instance-method-call.md` is replaced with "Branching control flow (now handled)" and "Closure / arrow-function lexical capture (now handled)" sections that document the live analysis. P2 of the RFC alignment sprint (see .claude/plans/rfc-bound-erased-generics-alignment/11-...md). Resolves Issue B from `.claude/review.md` plus both deferred items (branching unsoundness, closure `use (...)` imports) from the same review cycle's follow-up triage. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/roadmap.md | 6 +- docs/type-system/comparison.md | 23 +- .../Monomorphize/GenericMethodCompiler.php | 418 ++++++++++++- .../Monomorphize/XphpSourceParser.php | 25 +- .../GenericMethodIntegrationTest.php | 588 ++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 26 +- 6 files changed, 1050 insertions(+), 36 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index df12248..5a1e1ce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,8 +19,9 @@ timeline : generic interfaces : generic traits (template only — dropped after specialization) Function-level generics - : method-scoped generics — static calls only - : free generic functions at namespace scope + : method-scoped generics — static AND instance call sites + : free generic functions at namespace scope (and bare top-level) + : receiver-type analysis for `$this` / typed-param / typed-property / local `new` assignments Type-parameter bounds : single upper bound (e.g. T must extend Stringable) validated at compile time : enforced at class instantiation AND method / free-function call sites @@ -43,7 +44,6 @@ timeline : reified T as documented contract (T-class / instanceof T / is_a) : F-bounded recursion (T bounded by a generic of itself) Generic surface - : instance-method generic calls on a typed receiver : generic type aliases Compiler : Real cycle detection (replaces depth-cap heuristic) diff --git a/docs/type-system/comparison.md b/docs/type-system/comparison.md index 3f929a9..22ed96d 100644 --- a/docs/type-system/comparison.md +++ b/docs/type-system/comparison.md @@ -14,7 +14,7 @@ implement. | Feature | xphp | TS | Kotlin | Rust | |---------------------------|---------------------------|-----------------|-------------|---------------------| | Generic classes/ifaces | ✅ | ✅ | ✅ | ✅ | -| Generic functions/methods | ✅ (static-call only) | ✅ | ✅ | ✅ | +| Generic functions/methods | ✅ | ✅ | ✅ | ✅ | | Upper bounds | ✅ (single) | ✅ | ✅ | ✅ | | Multiple bounds | ❌ | ✅ | ✅ | ✅ | | Default type params | ❌ | ✅ | ✅ | ✅ | @@ -108,18 +108,7 @@ Bound validation (`Registry::checkBounds`) already loops per param at both the class-instantiation and method-call sites; trivially extends to loop per (param, bound) pair. The parser is the only real change. -### 4. Instance-method generic calls - -Currently: only `Util::method::(...)` (static call on a non-generic enclosing -class) is supported. `$obj->method::(...)` requires knowing the static type of -`$obj` to pick the receiver class. With strict typing on parameters and -properties, the static type is usually known at the call site; the unsolved part -is the dispatch table when `$obj` is a union / intersection / interface. - -Tracked in the [roadmap](../roadmap.md) under "Next". The compiler-level -lowering for runtime dispatch is the gap. - -### 5. Reified type parameters +### 4. Reified type parameters A headline Kotlin feature. Rust gets the same effect "for free" via monomorphization -- the type IS known at codegen time. @@ -137,7 +126,7 @@ code can't write `if ($x instanceof T)` and reason about it as a documented contract. Worth promoting from "accidentally works" to "documented capability" with `T::class`, `instanceof T`, and `is_a($x, T::class)` all guaranteed. -### 6. Generic type aliases +### 5. Generic type aliases - TypeScript: `type Result = ...` - Rust: `type Result = ...` @@ -311,6 +300,8 @@ unconstrained. `xphp` already pays for monomorphization, this is the user-facing payoff over Java /Kotlin. -### Instance-method generic calls +### Generic type aliases -Largest single uplift for day-to-day call-site ergonomics. +The remaining `Generic surface` item once instance-method generic calls +shipped. Same line of work -- expressive surface that composes with the +specialized-class output `xphp` already produces. diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index ec568a5..120c93b 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -5,16 +5,40 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\Match_; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; +use PhpParser\Node\MatchArm; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\NullableType; +use PhpParser\Node\Stmt\Case_; +use PhpParser\Node\Stmt\Catch_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Do_; +use PhpParser\Node\Stmt\Else_; +use PhpParser\Node\Stmt\ElseIf_; +use PhpParser\Node\Stmt\Finally_; +use PhpParser\Node\Stmt\For_; +use PhpParser\Node\Stmt\Foreach_; use PhpParser\Node\Stmt\Function_; +use PhpParser\Node\Stmt\If_; use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\Switch_; +use PhpParser\Node\Stmt\TryCatch; use PhpParser\Node\Stmt\Use_; +use PhpParser\Node\Stmt\While_; use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; @@ -296,6 +320,68 @@ private function rewriteCallSites( /** @var list */ public array $pendingAppends = []; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ + private ?string $currentClassFqn = null; + /** + * Local scope: parameter-name => resolved-class-FQN. Populated on entering a + * Function_ / ClassMethod by walking its `params` list and resolving each typed + * parameter. Used for receiver-type analysis on `$paramName->method::(...)`. + * + * @var array + */ + private array $currentScopeParamTypes = []; + /** + * Stage B local flow typing: variable-name => resolved-class-FQN. Populated by + * `enterNode` when it sees an `Assign($var, New_($className))` -- the lexical + * last-write determines the receiver type at later call sites in the same scope. + * + * Each Function_ / ClassMethod / Closure / ArrowFunction pushes a fresh scope + * onto `$scopeSnapshots`; the parent scope is restored on leave. Without the + * closure/arrow push, an inner `$x = new Bar()` overwrites the outer scope's + * `$x` slot, and the receiver type at a later outer call site picks the wrong + * class (the original review of b88539c caught this exact bug). + * + * @var array + */ + private array $currentScopeLocalTypes = []; + /** + * Snapshot stack for scope isolation across nested + * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the + * outgoing `(params, locals, branches)` triple; on leave we pop and restore. + * Branch snapshots are nested per-scope so that branches inside a closure + * don't leak to branches in the enclosing function. + * + * @var list, locals: array, branches: list, assigned: array}>}> + */ + private array $scopeSnapshots = []; + /** + * Branch frame stack for conservative reasoning across mutually-exclusive + * control-flow constructs (if/elseif/else, switch/case, match arms, + * while/for/foreach/do-while loops, try/catch/finally). Each frame holds: + * - `snapshot`: the value of `$currentScopeLocalTypes` at the moment the + * branching construct was entered; + * - `assigned`: the set of variable names that received an Assign anywhere + * inside the branch body. + * + * On enter of a branching parent we push a frame. On entering a sibling + * branch (else / elseif / case / catch / finally / match arm), we reset + * `currentScopeLocalTypes` from the top frame's snapshot -- each sibling + * starts from the pre-branch state, NOT from the previous sibling's + * mutations. On leave of the branching parent, we pop the frame, restore + * the snapshot, and INVALIDATE every variable in `assigned` (we can't + * tell at runtime whether the branch ran or not -- conservative says + * "we don't know the type"). The popped frame's `assigned` set + * propagates into the parent frame so nested branches stay reflected + * through the outer invalidation pass. + * + * Without this, `if ($cond) { $x = new Bar(); } $x->m::()` silently + * picks Bar (the last lexical write) regardless of whether the branch + * fired -- the original bug review of the post-b88539c work flagged. + * + * @var list, assigned: array}> + */ + private array $branchSnapshots = []; + /** * @param array $methodTemplates * @param array $classByFqn @@ -332,6 +418,118 @@ public function enterNode(Node $node): null $this->useMap[$alias] = $fqn; } } + if ($node instanceof ClassLike && $node->name !== null) { + $this->currentClassFqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $node->name->toString() + : $node->name->toString(); + } + if ($node instanceof Function_ + || $node instanceof ClassMethod + || $node instanceof Closure + || $node instanceof ArrowFunction + ) { + // Push outgoing scope (params + locals + branch stack) before + // computing the new one. The branch stack is per-scope: branches + // inside the closure are independent of branches in the parent. + $parentParams = $this->currentScopeParamTypes; + $parentLocals = $this->currentScopeLocalTypes; + $this->scopeSnapshots[] = [ + 'params' => $parentParams, + 'locals' => $parentLocals, + 'branches' => $this->branchSnapshots, + ]; + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->branchSnapshots = []; + + // For closures: `use ($x)` explicitly imports outer variables. + // Copy each imported name's type from the parent scope so the + // closure body can specialize `$x->m::(...)` correctly. + if ($node instanceof Closure) { + foreach ($node->uses as $use) { + if (!$use->var instanceof Variable || !is_string($use->var->name)) { + continue; + } + $importedName = $use->var->name; + $importedType = $parentParams[$importedName] + ?? $parentLocals[$importedName] + ?? null; + if ($importedType !== null) { + $this->currentScopeParamTypes[$importedName] = $importedType; + } + } + } + + // Arrow functions implicitly capture every outer variable by + // value. Copy all of parent's tracked types so the single- + // expression body can specialize the same way the parent could. + if ($node instanceof ArrowFunction) { + foreach ($parentParams as $importedName => $importedType) { + $this->currentScopeParamTypes[$importedName] = $importedType; + } + foreach ($parentLocals as $importedName => $importedType) { + $this->currentScopeParamTypes[$importedName] = $importedType; + } + } + + // Declared parameter types overwrite any imported same-named + // outer variable -- the param shadows the outer in PHP semantics. + foreach ($node->params as $param) { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $type = $param->type; + // Strip nullable wrapper: `?Container` is still "the receiver is Container" + // for method-resolution purposes (the runtime null-check is the caller's + // problem, not the type-resolution step). + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Name) { + $this->currentScopeParamTypes[$param->var->name] = $this->resolveClassName($type); + } + } + } + // Branching parents: push a frame so any Assign inside the branch + // body (or its sub-branches) gets recorded for post-leave + // invalidation. The visitor enters each parent ONCE; siblings + // (Else_/ElseIf_/Case_/Catch_/Finally_/MatchArm) reset + // currentScopeLocalTypes from the top frame's snapshot. + if (self::isBranchingParent($node)) { + $this->branchSnapshots[] = [ + 'snapshot' => $this->currentScopeLocalTypes, + 'assigned' => [], + ]; + } + if (self::isSiblingBranch($node) && $this->branchSnapshots !== []) { + $top = count($this->branchSnapshots) - 1; + $this->currentScopeLocalTypes = $this->branchSnapshots[$top]['snapshot']; + } + // Stage B flow typing: `$x = new ClassName(...)` records `$x`'s receiver + // type for later MethodCall sites in the same scope. Lexical last-write + // wins within a straight-line code path; branching constructs invalidate + // their assigned vars on leave (see branchSnapshots above). + if ($node instanceof Assign + && $node->var instanceof Variable + && is_string($node->var->name) + ) { + $assignedName = $node->var->name; + // Record the assignment in the innermost active branch frame + // regardless of RHS shape -- even a non-`new` assign poisons + // our tracked type for the post-leave invalidation. + if ($this->branchSnapshots !== []) { + $top = count($this->branchSnapshots) - 1; + $this->branchSnapshots[$top]['assigned'][$assignedName] = true; + } + // Update the live tracked type only when the RHS is `new ClassName(...)` + // -- that's the one shape we can prove statically. Other RHS + // shapes are conservatively ignored (they could be anything). + if ($node->expr instanceof New_ + && $node->expr->class instanceof Name + ) { + $this->currentScopeLocalTypes[$assignedName] = $this->resolveClassName($node->expr->class); + } + } return null; } @@ -343,9 +541,88 @@ public function leaveNode(Node $node): ?Node if ($node instanceof FuncCall) { return $this->rewriteFuncCall($node); } + if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) { + return $this->rewriteInstanceMethodCall($node); + } + if ($node instanceof ClassLike) { + $this->currentClassFqn = null; + } + if ($node instanceof Function_ + || $node instanceof ClassMethod + || $node instanceof Closure + || $node instanceof ArrowFunction + ) { + $snapshot = array_pop($this->scopeSnapshots); + if ($snapshot !== null) { + $this->currentScopeParamTypes = $snapshot['params']; + $this->currentScopeLocalTypes = $snapshot['locals']; + $this->branchSnapshots = $snapshot['branches']; + } else { + // Defensive: matched enter/leave count is invariant of the + // NodeTraverser; the else-branch is only reachable if the AST + // is malformed. Fall back to empty scope to avoid an undefined + // pop on the next leave. + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->branchSnapshots = []; + } + } + // Branching parents: pop the frame, restore the pre-branch local + // types, then invalidate every variable that received an Assign + // anywhere inside the branch body. Propagate the popped frame's + // assigned set into the parent frame so nested branches contribute + // to the outer invalidation pass. + if (self::isBranchingParent($node)) { + $popped = array_pop($this->branchSnapshots); + if ($popped !== null) { + $this->currentScopeLocalTypes = $popped['snapshot']; + foreach ($popped['assigned'] as $assignedName => $_true) { + unset($this->currentScopeLocalTypes[$assignedName]); + if ($this->branchSnapshots !== []) { + $parentTop = count($this->branchSnapshots) - 1; + $this->branchSnapshots[$parentTop]['assigned'][$assignedName] = true; + } + } + } + } return null; } + /** + * Branching parents push a fresh frame on enter and pop on leave. These + * are the control-flow constructs whose body MAY OR MAY NOT execute (or + * may execute MULTIPLE TIMES, in the case of loops). Either way, the + * receiver-type tracker can't rely on the body's assignments to hold + * post-leave. + */ + private static function isBranchingParent(Node $node): bool + { + return $node instanceof If_ + || $node instanceof Switch_ + || $node instanceof Match_ + || $node instanceof While_ + || $node instanceof Do_ + || $node instanceof For_ + || $node instanceof Foreach_ + || $node instanceof TryCatch; + } + + /** + * Sibling-branch nodes (Else_, ElseIf_, the cases of Switch_, the arms + * of Match_, Catch_/Finally_ on TryCatch). Each sibling starts from the + * pre-branch state -- without the reset, the else body would see the + * if body's mutations and pick the wrong receiver class. + */ + private static function isSiblingBranch(Node $node): bool + { + return $node instanceof Else_ + || $node instanceof ElseIf_ + || $node instanceof Case_ + || $node instanceof MatchArm + || $node instanceof Catch_ + || $node instanceof Finally_; + } + private function rewriteStaticCall(StaticCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -406,6 +683,131 @@ private function rewriteStaticCall(StaticCall $node): ?Node return $node; } + /** + * Instance-method turbofish rewrite. Same mangling / append shape as the + * StaticCall path; the only new piece is `resolveReceiverFqn` -- the + * receiver-type analysis that says "this $obj is statically of type X" so + * we can pick the right method template from $methodTemplates. + * + * Stage A coverage (this commit): + * - `$this->method::(...)` -- receiver is the enclosing class. + * - `$param->method::(...)` -- receiver is the function/method + * parameter's declared type (snapshot in $currentScopeParamTypes). + * + * Receivers we currently can't resolve (returns null -> no + * specialization, marker drops silently; user's call site becomes a + * normal MethodCall to a method that doesn't exist post-strip, surfacing + * a runtime "undefined method" error). Stage B will widen the receiver + * sources to local-variable assignments. + */ + private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): ?Node + { + $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args) || $args === [] || !self::allConcrete($args)) { + return null; + } + if (!$node->name instanceof Identifier) { + return null; + } + + $classFqn = $this->resolveReceiverFqn($node->var); + if ($classFqn === null) { + return null; + } + $methodName = $node->name->toString(); + $key = $classFqn . '::' . $methodName; + $template = $this->methodTemplates[$key] ?? null; + if ($template === null) { + return null; + } + $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + + if ($this->hierarchy !== null) { + Registry::checkBounds( + $params, + $args, + $this->hierarchy, + $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', + ); + } + + $mangled = self::mangleName($methodName, $args, $this->hashLength); + $generatedKey = $classFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$generatedKey])) { + $substitution = []; + foreach ($params as $i => $param) { + $substitution[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod($template, $substitution, $mangled); + $owner = $this->classByFqn[$classFqn] ?? null; + if ($owner !== null) { + $this->pendingAppends[] = [$owner, $specialized]; + $this->alreadyGenerated[$generatedKey] = true; + } + } + + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + + return $node; + } + + /** + * Resolve the static type (FQN) of a method-call receiver expression. + * Returns null when the receiver type can't be determined -- the caller + * uses null to mean "no specialization, leave the call site alone". + * + * Stage A handles two shapes: + * - `$this` -> enclosing class FQN (tracked on ClassLike enter). + * - `$paramName` where paramName has a typed declaration in the + * current function/method's signature. + */ + private function resolveReceiverFqn(Node $receiver): ?string + { + if ($receiver instanceof Variable && is_string($receiver->name)) { + if ($receiver->name === 'this') { + return $this->currentClassFqn; + } + return $this->currentScopeParamTypes[$receiver->name] + ?? $this->currentScopeLocalTypes[$receiver->name] + ?? null; + } + if ($receiver instanceof PropertyFetch + && $receiver->var instanceof Variable + && $receiver->var->name === 'this' + && $receiver->name instanceof Identifier + && $this->currentClassFqn !== null + ) { + // `$this->prop->method::(...)` -- look up `prop`'s declared + // type on the current class. + $owner = $this->classByFqn[$this->currentClassFqn] ?? null; + if ($owner !== null) { + $propName = $receiver->name->toString(); + foreach ($owner->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = $stmt->type; + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Name) { + return $this->resolveClassName($type); + } + } + } + } + } + return null; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -476,10 +878,24 @@ private function rewriteFuncCall(FuncCall $node): ?Node private function resolveClassName(Name $name): string { + $raw = $name->toString(); + // Pseudo-types short-circuit to the enclosing class FQN. Without this, + // a parameter typed `self` would resolve to `App\…\self` (a phantom + // class), and any `$param->m::()` call on it would miss the + // template lookup. Same gap as the scanner's pseudo-type filter -- + // they need to stay in sync. `currentClassFqn` is null only at top + // level (no enclosing ClassLike), where pseudo-types aren't legal + // anyway; fall through to the namespace path so the user sees PHP's + // own "cannot use self outside class context" error. + $lower = strtolower($raw); + if ($this->currentClassFqn !== null + && ($lower === 'self' || $lower === 'static' || $lower === 'parent') + ) { + return $this->currentClassFqn; + } if ($name instanceof FullyQualified) { return $name->toString(); } - $raw = $name->toString(); if (str_starts_with($raw, '\\')) { return ltrim($raw, '\\'); } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 2c877f3..38b1436 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -267,10 +267,10 @@ private function scanAndStrip(string $source): array $parsed = self::parseTypeArgList($tokens, $afterDc); if ($parsed !== null) { [$args, $endIdx] = $parsed; - // @todo MethodCall resolver branch is pending; for - // `$obj->m::<…>(...)` the marker is recorded but - // not claimed today -- the strip on its own is - // enough to keep the cleaned source valid PHP. + // Instance-method turbofish (`$obj->m::<…>(...)`) markers are + // claimed by the MethodCall / NullsafeMethodCall resolver branch + // alongside StaticCall (item #11). GenericMethodCompiler does + // receiver-type analysis to pick the right method template. $nameMarkers[] = [ 'line' => $nameLine, 'anchorLine' => $anchorLine, @@ -847,14 +847,21 @@ public function enterNode(Node $node): null $this->typeParamStack[] = $matchedParamNames; } - if ($node instanceof Node\Expr\StaticCall && $node->name instanceof Node\Identifier) { + if (($node instanceof Node\Expr\StaticCall + || $node instanceof Node\Expr\MethodCall + || $node instanceof Node\Expr\NullsafeMethodCall) + && $node->name instanceof Node\Identifier + ) { $callMethodName = $node->name->toString(); $startLine = $node->getStartLine(); foreach ($this->nameMarkers as $i => $marker) { - // Match by name + line-range overlap. StaticCall::getStartLine() is the - // receiver's line; the marker's anchorLine is the same, and its - // (later) line is the identifier's line. Both can differ on multi-line - // `Foo::\n method` constructs. + // Match by name + line-range overlap. The Call node's + // getStartLine() is the receiver's line; the marker's anchorLine + // is the same, and its (later) line is the identifier's line. + // Both can differ on multi-line `Foo::\n method::` or + // `$obj->\n method::` constructs. The same logic now + // covers static, instance, and nullsafe method calls -- the + // GenericMethodCompiler distinguishes them later by AST type. if ($marker['name'] === $callMethodName && $startLine >= $marker['anchorLine'] && $startLine <= $marker['line'] diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 0af2f51..9d0fdf0 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -318,6 +318,594 @@ public function withItem(T $n): self } } + public function testInstanceMethodGenericThisReceiverSpecializes(): void + { + // Phase 2 Stage A1: `$this->method::(...)` -- the most common shape. + // Receiver type is the enclosing class, no flow analysis needed. + $dir = sys_get_temp_dir() . '/xphp-inst-this-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Util.xphp', <<<'PHP' + (T $x): T { return $x; } + public function callIntIdentity(): int + { + return $this->identity::(42); + } + public function callStringIdentity(): string + { + return $this->identity::('hi'); + } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + callIntIdentity(); + $s = $u->callStringIdentity(); + PHP); + + try { + $this->compileFrom($dir); + $util = file_get_contents($dir . '/dist/Util.php'); + self::assertIsString($util); + // Both turbofish call sites rewritten to mangled identifiers. + self::assertMatchesRegularExpression( + '/\$this->identity_T_[0-9a-f]+\(42\)/', + $util, + '$this->identity:: rewritten to mangled name', + ); + self::assertMatchesRegularExpression( + "/\\\$this->identity_T_[0-9a-f]+\\('hi'\\)/", + $util, + ); + // Two specialized methods appended to Util. + self::assertSame( + 2, + preg_match_all('/public function identity_T_[0-9a-f]+\(/', $util), + ); + self::assertStringNotContainsString('function identity(', $util); + + // Runtime sanity: the rewritten class actually executes. + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('i=42;s=hi', $output); + } finally { + self::rrmdir($dir); + } + } + + public function testInstanceMethodGenericParamReceiverSpecializes(): void + { + // Phase 2 Stage A2: receiver is a parameter with a typed declaration. + // `function go(Util $u) { $u->identity::(7); }` resolves $u to Util + // via the parameter type, no flow analysis needed. + $dir = sys_get_temp_dir() . '/xphp-inst-param-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Util.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Caller.xphp', <<<'PHP' + identity::(7); + } + public function viaNullableParam(?Util $u): ?int + { + return $u?->identity::(11); + } + } + PHP); + + try { + $this->compileFrom($dir); + $caller = file_get_contents($dir . '/dist/Caller.php'); + self::assertIsString($caller); + self::assertMatchesRegularExpression( + '/\$u->identity_T_[0-9a-f]+\(7\)/', + $caller, + 'parameter receiver: $u resolved via param type', + ); + self::assertMatchesRegularExpression( + '/\$u\?->identity_T_[0-9a-f]+\(11\)/', + $caller, + 'nullable parameter receiver: nullable wrapper stripped before type lookup', + ); + + $util = file_get_contents($dir . '/dist/Util.php'); + self::assertIsString($util); + self::assertMatchesRegularExpression('/public function identity_T_[0-9a-f]+\(int \$x\): int/', $util); + } finally { + self::rrmdir($dir); + } + } + + public function testInstanceMethodGenericLocalVariableReceiverSpecializes(): void + { + // Phase 2 Stage B: local flow typing. `$u = new Util(); $u->m::(...)` + // -- the visitor records `$u`'s type from the assignment so the later + // method call can specialize. Lexical last-write wins; we don't model + // branches or method-return-typed reassignments. + $dir = sys_get_temp_dir() . '/xphp-inst-local-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Util.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + identity::(99); + $s = $u->identity::('world'); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$u->identity_T_[0-9a-f]+\(99\)/', + $use, + 'local var: $u flow-typed from `new Util()`', + ); + self::assertMatchesRegularExpression( + "/\\\$u->identity_T_[0-9a-f]+\\('world'\\)/", + $use, + ); + + // Runtime sanity check. + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('i=99;s=world', $output); + } finally { + self::rrmdir($dir); + } + } + + public function testInstanceMethodGenericPropertyReceiverSpecializes(): void + { + // Bonus: `$this->prop->method::(...)` where prop is a typed property. + $dir = sys_get_temp_dir() . '/xphp-inst-prop-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Util.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Owner.xphp', <<<'PHP' + util = new Util(); + } + public function go(): int + { + return $this->util->identity::(123); + } + } + PHP); + + try { + $this->compileFrom($dir); + $owner = file_get_contents($dir . '/dist/Owner.php'); + self::assertIsString($owner); + self::assertMatchesRegularExpression( + '/\$this->util->identity_T_[0-9a-f]+\(123\)/', + $owner, + 'property receiver: $this->util resolved via property type declaration', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testReceiverTypeAnalysisDoesNotLeakAcrossClosureScopes(): void + { + // Regression for the review of b88539c (Issue B): receiver-type analysis + // shared `$currentScopeLocalTypes` across closure boundaries, so an inner + // `$x = new Bar()` overwrote the outer scope's `$x = new Foo()` slot. + // The outer call after the closure returned then picked Bar's mangled + // method (often a method that didn't exist on Foo) and Foo never got + // its specialization generated. + // + // Fix: snapshot/restore $currentScopeParamTypes + $currentScopeLocalTypes + // on Closure (and ArrowFunction) enter/leave the same way Function_ and + // ClassMethod already did. Closure body gets a fresh scope; outer scope + // is restored on leave. + $dir = sys_get_temp_dir() . '/xphp-leak-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Bar.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + barId::(11); + }; + $cb(); + $outer = $x->fooId::(22); + PHP); + + try { + $this->compileFrom($dir); + + // Foo must receive its `fooId_T_` specialization, NOT silently + // get nothing (which was the original failure mode). + $foo = file_get_contents($dir . '/dist/Foo.php'); + self::assertIsString($foo); + self::assertMatchesRegularExpression( + '/public function fooId_T_[0-9a-f]+\(int \$x\): int/', + $foo, + 'Foo must receive its specialized method -- the outer-scope receiver', + ); + + // Bar still gets its inner-scope specialization. + $bar = file_get_contents($dir . '/dist/Bar.php'); + self::assertIsString($bar); + self::assertMatchesRegularExpression( + '/public function barId_T_[0-9a-f]+\(int \$x\): int/', + $bar, + ); + + // Call sites: inner uses Bar's mangled name, outer uses Foo's. + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->barId_T_[0-9a-f]+\(11\)/', + $use, + 'inner closure call uses Bar barId mangled name', + ); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(22\)/', + $use, + 'outer call uses Foo fooId mangled name -- proves the scope was restored', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testReceiverTypeAnalysisDoesNotLeakAcrossArrowFunction(): void + { + // Arrow functions can't reassign outer variables in PHP semantics (a + // single-expression body has nowhere to assign), but the snapshot / + // restore on `ArrowFunction` enter/leave is symmetric with Closure + // for invariant safety. This test pins the arrow-function shape so a + // future refactor that loses the symmetry can't quietly regress. + $dir = sys_get_temp_dir() . '/xphp-arrow-leak-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + $x * 2; + $r = $double(21); + $outer = $x->fooId::(7); + PHP); + + try { + $this->compileFrom($dir); + + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(7\)/', + $use, + 'outer Foo call survives the arrow function body', + ); + + $foo = file_get_contents($dir . '/dist/Foo.php'); + self::assertIsString($foo); + self::assertMatchesRegularExpression( + '/public function fooId_T_[0-9a-f]+\(int \$x\): int/', + $foo, + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingReassignmentInvalidatesPostBranchSpecialization(): void + { + // Bug fix: `$x = new Foo(); if (…) { $x = new Bar(); } $x->m::()` + // used to specialize against Bar (the last lexical write) regardless + // of whether the branch fired. The conservative fix invalidates `$x` + // on the branch's exit -- the post-branch call site no longer + // specializes, and PHP throws "undefined method" at runtime instead + // of silently calling the wrong specialization. + $dir = sys_get_temp_dir() . '/xphp-br-post-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Bar.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(7); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + // Post-branch call must NOT be specialized -- the receiver type is + // ambiguous after the conditional reassignment. + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'post-branch call must not specialize when receiver was conditionally reassigned', + ); + // The bare unmangled name should survive into the cleaned output. + self::assertStringContainsString('$x->fooId(7)', $use); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingIntraBranchSpecializationStillWorks(): void + { + // Conservative branching analysis must NOT lose the intra-branch + // specialization -- within the if-body we know exactly what `$x` is, + // so calls there are still resolvable. + $dir = sys_get_temp_dir() . '/xphp-br-intra-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(1); + } + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(1\)/', + $use, + 'intra-branch call site must specialize -- the branch knows the type', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingElseBranchSeesPreBranchState(): void + { + // Bug fix: with the sibling-branch reset, the else-body now sees the + // pre-if state of every variable, NOT the if-body's mutations. So + // `$y = Foo; if (…) { $y = Bar; } else { $y->fooId::(); }` specializes + // the else call against Foo, not against Bar. + $dir = sys_get_temp_dir() . '/xphp-br-else-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Bar.xphp', <<<'PHP' + fooId::(2); + } + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$y->fooId_T_[0-9a-f]+\(2\)/', + $use, + 'else branch must see the pre-if state of $y (Foo, not Bar)', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testClosureUseImportPreservesReceiverType(): void + { + // Bug fix: closures with explicit `use ($x)` now import the type of + // `$x` from the parent scope so `$x->m::(...)` inside the closure + // body can specialize. Without this, the body's specialized call + // site was silently dropped (the visitor's fresh-scope-per-closure + // had no knowledge of $x). + $dir = sys_get_temp_dir() . '/xphp-imp-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + id::(11); + }; + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->id_T_[0-9a-f]+\(11\)/', + $use, + 'closure with `use ($x)` must specialize $x->id:: via the imported type', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testArrowFunctionImplicitCapturePreservesReceiverType(): void + { + // Bug fix: arrow functions automatically capture every outer + // variable. The receiver-type analysis must now copy parent-scope + // params + locals into the arrow function's scope so the body's + // call sites can specialize. + $dir = sys_get_temp_dir() . '/xphp-arrow-imp-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + $x->id::(22); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->id_T_[0-9a-f]+\(22\)/', + $use, + 'arrow function must inherit outer $x type via implicit capture', + ); + } finally { + self::rrmdir($dir); + } + } + + private function compileFrom(string $dir): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($dir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $dir, $dir . '/dist', $dir . '/.xphp-cache'); + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 81dd696..e3fa49e 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1306,12 +1306,12 @@ public function testTurbofishOnStaticMethodCallIsRecognized(): void self::assertSame('int', $args[0]->name); } - public function testTurbofishOnInstanceMethodCallStripsButHasNoResolverYet(): void + public function testTurbofishOnInstanceMethodCallIsRecognized(): void { - // Instance-method generic specialization is on the roadmap but not yet - // wired (no MethodCall branch in the resolver). The scanner still has to - // strip the `::<…>` so the cleaned source is plain PHP -- otherwise the - // file would refuse to parse at all. + // Instance-method turbofish (`$obj->method::(...)`) -- the resolver + // now claims the marker and attaches it to the MethodCall node, alongside + // the scanner's strip. GenericMethodCompiler does receiver-type analysis + // to pick the right method template at specialization time. $source = <<<'PHP' map::($fn); @@ -1323,13 +1323,17 @@ public function testTurbofishOnInstanceMethodCallStripsButHasNoResolverYet(): vo self::assertStringNotContainsString('', $stripped); self::assertStringContainsString('$obj->map', $stripped); - // Sanity: the cleaned source actually parses as PHP. $ast = $parser->parse($source); $call = self::findFirstNodeOfType($ast, Node\Expr\MethodCall::class); self::assertNotNull($call); + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('string', $args[0]->name); + self::assertTrue($args[0]->isScalar); } - public function testTurbofishOnNullsafeInstanceMethodCallIsStripped(): void + public function testTurbofishOnNullsafeInstanceMethodCallIsRecognized(): void { $source = <<<'PHP' ', $stripped); self::assertStringContainsString('$obj?->map', $stripped); + + $ast = $parser->parse($source); + $call = self::findFirstNodeOfType($ast, Node\Expr\NullsafeMethodCall::class); + self::assertNotNull($call); + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('string', $args[0]->name); } public function testBareNewCallSiteIsRejectedAndLeftUnstripped(): void From 2baca049f6be617145b18080f23dc7c00bd4703b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 5 Jun 2026 20:29:44 +0000 Subject: [PATCH 11/36] feat(parser): multi-bound + BoundExpr + F-bounded recursion Widen the bound representation from a single FQN string to a dedicated `BoundExpr` tree on `TypeParam.bound`, then ship the scanner combinators for `&` / `|` / DNF parens and F-bounded recursion (`T : Box`). The bound tree lives separately from `TypeRef` so the `name + isScalar + isTypeParam` invariant the rest of the pipeline depends on stays clean. BoundExpr only appears at bound positions, never at instantiation args or method/return type slots. New value objects: - `BoundExpr` abstract base (readonly). - `BoundLeaf(TypeRef)` -- a single class / interface reference; carries a TypeRef so F-bounded `Comparable` works without a second representation. Inner generic args are resolved against the enclosing type-param scope (T marks `isTypeParam: true`). - `BoundIntersection(...BoundExpr)` -- `A & B`; all-must-satisfy. - `BoundUnion(...BoundExpr)` -- `A | B`; any-suffices. DNF builds as `BoundUnion(BoundIntersection(A, B), C)` -- natural outer-OR-of- inner-ANDs nesting. Scanner combinators (`XphpSourceParser`): - `parseBoundExpr` -- recursive descent. Grammar: bound := orBound orBound := andBound ('|' andBound)* andBound := primary ('&' primary)* primary := '(' bound ')' | leaf leaf := Name typeArgList? // typeArgList for F-bounded - `parseOrBound` / `parseAndBound` / `parsePrimaryBound` / `parseLeafBound` return `[bound_array, newIdx]` or null. Single-operand `or` and `and` collapse to their inner operand so a plain `T : Foo` stays a leaf in the resulting tree. - F-bounded shapes consume an inner `parseTypeArgList` (the same machinery used at instantiation sites) so nested generic args resolve correctly. Resolver (`buildBoundExpr` in the inner visitor): walks the parsed bound tree and produces a `BoundExpr`. Leaf names route through `resolveNameOnly` for namespace + use-map resolution; leaf args route through `resolveTypeRefList`. The enclosing class/method's type-params are now pushed onto `$typeParamStack` BEFORE the bound is built (two-pass within the ClassLike / ClassMethod / Function_ branch), so F-bounded `T : Box` sees its own T on the resolution stack. Self-reference guard (`assertNoTopLevelSelfReference`): recursively walks the bound tree. Any leaf whose name matches the param name AND isn't fully qualified AND has no generic args is rejected. F-bounded forms (`T : Box`) explicitly survive because the inner T is inside `Box`'s args, not a top-level leaf. Registry combinator (`Registry::evaluateBound`): - Leaf: delegates to `TypeHierarchy::isSubtype`. - Intersection: any false -> false; all true -> true; any null + no false -> null. (Conservative under unknown operands.) - Union: any true -> true; all false -> false; any null + no true -> null. Error message renders the bound in source form (`A & B`, `A | B`, or `(A & B) | C` for DNF). Single-leaf bounds keep the original "does not extend/implement" wording so the existing tests still match; compound bounds get "does not satisfy" instead. F-bounded termination is safe under the existing fixed-point depth cap: the bound check is shallow (one substitution into `Comparable`, erased subtype check against `Comparable` -- the args are not consulted by `TypeHierarchy::isSubtype`). No recursive `recordInstantiation` runs. Fixtures (`test/fixture/compile/`): - `bounds_intersection/` -- `Stringable & Countable` satisfied by Tag. - `bounds_union/` -- `Stringable | Countable` exercised with both arms via StringableOnly / CountableOnly, generates two specializations. - `bounds_dnf/` -- `(Stringable & Countable) | Iterator` exercised with StringableCountable (left arm) and IteratorOnly (right arm). - `bounds_f_bounded/` -- `Sortable>` with Tag implementing `Comparable`. Tests: parser + integration coverage for intersection, union, DNF, and F-bounded; symmetric parens rendering in both intersection-of-union and union-of-intersection error messages; three-way verdict (true/false/null) for unknown operands on both combinators; recursive self-reference guard. Infection: new bound sub-parsers added to existing `LessThan` / `LogicalAnd` / `LogicalOr` / `GreaterThanOrEqualTo` / `IncrementInteger` ignore lists with rationales matching the existing parser-helper entries (boundary-check mutants are absorbed by inner-function defensive guards; reaching the OOB case requires malformed input that fails further downstream). Test count 217 -> 236; MSI 100%. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 36 +- src/Transpiler/Monomorphize/BoundExpr.php | 36 ++ .../Monomorphize/BoundIntersection.php | 24 ++ src/Transpiler/Monomorphize/BoundLeaf.php | 21 ++ src/Transpiler/Monomorphize/BoundUnion.php | 27 ++ src/Transpiler/Monomorphize/Registry.php | 98 ++++- src/Transpiler/Monomorphize/TypeParam.php | 17 +- .../Monomorphize/XphpSourceParser.php | 342 +++++++++++++++--- .../BoundedGenericIntegrationTest.php | 334 +++++++++++++++++ .../GenericFunctionIntegrationTest.php | 7 +- .../GenericInterfaceIntegrationTest.php | 8 +- .../GenericMethodIntegrationTest.php | 14 +- .../Monomorphize/RegistryBoundsTest.php | 14 +- .../Monomorphize/XphpSourceParserTest.php | 237 +++++++++++- .../bounds_dnf/source/Containers/Box.xphp | 12 + .../source/Models/IteratorOnly.xphp | 48 +++ .../source/Models/StringableCountable.xphp | 23 ++ .../compile/bounds_dnf/source/Use.xphp | 15 + .../source/Containers/Sortable.xphp | 17 + .../source/Contracts/Comparable.xphp | 12 + .../bounds_f_bounded/source/Models/Tag.xphp | 21 ++ .../compile/bounds_f_bounded/source/Use.xphp | 10 + .../source/Containers/Box.xphp | 17 + .../source/Models/Tag.xphp | 22 ++ .../bounds_intersection/source/Use.xphp | 10 + .../bounds_union/source/Containers/Box.xphp | 12 + .../source/Models/CountableOnly.xphp | 20 + .../source/Models/StringableOnly.xphp | 17 + .../compile/bounds_union/source/Use.xphp | 13 + 29 files changed, 1392 insertions(+), 92 deletions(-) create mode 100644 src/Transpiler/Monomorphize/BoundExpr.php create mode 100644 src/Transpiler/Monomorphize/BoundIntersection.php create mode 100644 src/Transpiler/Monomorphize/BoundLeaf.php create mode 100644 src/Transpiler/Monomorphize/BoundUnion.php create mode 100644 test/fixture/compile/bounds_dnf/source/Containers/Box.xphp create mode 100644 test/fixture/compile/bounds_dnf/source/Models/IteratorOnly.xphp create mode 100644 test/fixture/compile/bounds_dnf/source/Models/StringableCountable.xphp create mode 100644 test/fixture/compile/bounds_dnf/source/Use.xphp create mode 100644 test/fixture/compile/bounds_f_bounded/source/Containers/Sortable.xphp create mode 100644 test/fixture/compile/bounds_f_bounded/source/Contracts/Comparable.xphp create mode 100644 test/fixture/compile/bounds_f_bounded/source/Models/Tag.xphp create mode 100644 test/fixture/compile/bounds_f_bounded/source/Use.xphp create mode 100644 test/fixture/compile/bounds_intersection/source/Containers/Box.xphp create mode 100644 test/fixture/compile/bounds_intersection/source/Models/Tag.xphp create mode 100644 test/fixture/compile/bounds_intersection/source/Use.xphp create mode 100644 test/fixture/compile/bounds_union/source/Containers/Box.xphp create mode 100644 test/fixture/compile/bounds_union/source/Models/CountableOnly.xphp create mode 100644 test/fixture/compile/bounds_union/source/Models/StringableOnly.xphp create mode 100644 test/fixture/compile/bounds_union/source/Use.xphp diff --git a/infection.json5 b/infection.json5 index 3d6f0e4..d00633e 100644 --- a/infection.json5 +++ b/infection.json5 @@ -62,6 +62,13 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", + // Bound sub-parsers' `+1` advance after consuming `|` / `&` / + // a leaf clause. Off-by-one mutations land on tokens that the + // next iteration's skipWs absorbs (skipped whitespace, the + // already-consumed operator, or the closing `>` of an arg list). + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseOrBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseAndBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parsePrimaryBound", // scanAndStrip's `$j = self::skipWs($tokens, $i + 1)` lookaheads: // off-by-one mutations get absorbed by the outer loop's $i++ on the // next pass (skipped tokens are non-significant whitespace or the @@ -225,7 +232,12 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach", + // Bound sub-parsers' `||` guards (`$idx >= $n || tokens[$idx] !== X`): + // same rationale as the scanner helpers above. Both branches are + // unreachable for valid bound expressions. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parsePrimaryBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound" ] }, "ReturnRemoval": { @@ -307,6 +319,14 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", + // Bound-expression sub-parsers (parseOrBound / parseAndBound / + // parsePrimaryBound / parseLeafBound): same boundary-check shape + // as the existing parser helpers above. `<=` / `<` mutations on + // the lookahead guards are absorbed by the inner functions' + // defensive guards; reaching the OOB case requires malformed + // input that fails further downstream (nikic rejects the cleaned + // source before any marker attaches). + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", // ByteOffsetMap::toOriginal: `$strippedPos < $strippedStart` -> `<=`. // At the exact boundary, `strippedPos + priorDelta == originalStart` // by construction (cumDelta cancellation), so both branches return @@ -320,6 +340,11 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach", + // parseLeafBound: `$afterName < $n && $tokens[$afterName]->text === '<'` + // -- `&&` -> `||` produces a guard whose alternate branch is + // unreachable for valid bound expressions (the `<` token only + // appears at positions reachable via the safe path). + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction" @@ -331,6 +356,15 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", + // Bound sub-parsers' end-of-stream guards (parseOrBound / + // parseAndBound / parsePrimaryBound / parseLeafBound): + // `$idx >= $n` -> `>`. The boundary cases land on the very-last + // token of the source, which nikic would have rejected before + // markers attach -- same rationale as the existing entries above. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseOrBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseAndBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parsePrimaryBound", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext" ] }, diff --git a/src/Transpiler/Monomorphize/BoundExpr.php b/src/Transpiler/Monomorphize/BoundExpr.php new file mode 100644 index 0000000..ce7cec0 --- /dev/null +++ b/src/Transpiler/Monomorphize/BoundExpr.php @@ -0,0 +1,36 @@ +`). + * - `BoundIntersection` — `A & B & ...`; every operand must satisfy. + * - `BoundUnion` — `A | B | ...`; any operand suffices. + * + * Nesting `BoundUnion(BoundIntersection(A, B), C)` builds `(A & B) | C` (DNF). + * + * The leaf carries a `TypeRef` rather than a bare FQN so F-bounded recursion + * (`T : Comparable`) shares the existing TypeRef substitution machinery. + * + * `TypeRef` itself is NOT widened to carry compound shapes — the rest of the + * pipeline (Specializer, CallSiteRewriter, GenericMethodCompiler) treats + * `TypeRef` as a single concrete-or-type-param slot, and adding `isUnion` / + * `isIntersection` flags would break the `name + isScalar + isTypeParam` + * invariant the existing canonicalisation + hashing depend on. Keeping + * `BoundExpr` separate isolates the schema explosion to just the bound + * positions. + */ +abstract readonly class BoundExpr +{ +} diff --git a/src/Transpiler/Monomorphize/BoundIntersection.php b/src/Transpiler/Monomorphize/BoundIntersection.php new file mode 100644 index 0000000..020309b --- /dev/null +++ b/src/Transpiler/Monomorphize/BoundIntersection.php @@ -0,0 +1,24 @@ + false (definite failure) + * - all operands `true` -> true + * - otherwise (mix of true and null, no false) -> null (unknown) + */ +final readonly class BoundIntersection extends BoundExpr +{ + /** @var list */ + public array $operands; + + public function __construct(BoundExpr ...$operands) + { + $this->operands = $operands; + } +} diff --git a/src/Transpiler/Monomorphize/BoundLeaf.php b/src/Transpiler/Monomorphize/BoundLeaf.php new file mode 100644 index 0000000..cada2dc --- /dev/null +++ b/src/Transpiler/Monomorphize/BoundLeaf.php @@ -0,0 +1,21 @@ +` work without a + * second representation. + * + * Subtype check at instantiation time: `$hierarchy->isSubtype($concrete->name, $leaf->type->name)` + * -- erased to nominal class names since the hierarchy doesn't model generic args. + */ +final readonly class BoundLeaf extends BoundExpr +{ + public function __construct( + public TypeRef $type, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/BoundUnion.php b/src/Transpiler/Monomorphize/BoundUnion.php new file mode 100644 index 0000000..a16f8d1 --- /dev/null +++ b/src/Transpiler/Monomorphize/BoundUnion.php @@ -0,0 +1,27 @@ + true (definite success) + * - all operands `false` -> false + * - otherwise (mix of false and null, no true) -> null (unknown) + * + * DNF builds as a `BoundUnion(BoundIntersection(...), BoundIntersection(...), ...)` — + * the natural outer-OR-of-inner-ANDs nesting. + */ +final readonly class BoundUnion extends BoundExpr +{ + /** @var list */ + public array $operands; + + public function __construct(BoundExpr ...$operands) + { + $this->operands = $operands; + } +} diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 9dd135f..6e7841b 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -166,20 +166,28 @@ public static function checkBounds( return; } foreach ($typeParams as $i => $param) { - if ($param->boundFqn === null) { + if ($param->bound === null) { continue; } $concrete = $args[$i]; - $verdict = $hierarchy->isSubtype($concrete->name, $param->boundFqn); + $verdict = self::evaluateBound($param->bound, $concrete, $hierarchy); if ($verdict === true) { continue; } + $boundDisplay = self::formatBound($param->bound); + // Single-leaf bounds keep the original "extend/implement" wording + // (the relationship is a direct extends/implements query against the + // class hierarchy). Compound bounds (intersection / union / DNF) + // can't be described that way, so they get "satisfy" instead. + $isSimpleLeaf = $param->bound instanceof BoundLeaf; $detail = $verdict === false - ? sprintf('"%s" does not extend/implement "%s".', $concrete->toDisplayString(), $param->boundFqn) + ? ($isSimpleLeaf + ? sprintf('"%s" does not extend/implement "%s".', $concrete->toDisplayString(), $boundDisplay) + : sprintf('"%s" does not satisfy "%s".', $concrete->toDisplayString(), $boundDisplay)) : sprintf( '"%s" is not in the source set the hierarchy was built from (and is not a recognized PHP built-in, or its bound satisfaction comes via a trait the compiler does not yet follow), so the compiler cannot prove it satisfies "%s".', $concrete->toDisplayString(), - $param->boundFqn, + $boundDisplay, ); throw new RuntimeException(sprintf( "Generic bound violated while instantiating %s.\n" @@ -188,13 +196,93 @@ public static function checkBounds( . " %s", $instantiationLabel, $param->name, - $param->boundFqn, + $boundDisplay, $concrete->toDisplayString(), $detail, )); } } + /** + * Three-way verdict (true / false / null) for a bound expression against a + * concrete TypeRef. Walks the BoundExpr tree: + * - Leaf: delegates to `$hierarchy->isSubtype`. + * - Intersection: any false -> false; all true -> true; otherwise null. + * - Union: any true -> true; all false -> false; otherwise null. + */ + private static function evaluateBound(BoundExpr $bound, TypeRef $concrete, TypeHierarchy $hierarchy): ?bool + { + if ($bound instanceof BoundLeaf) { + return $hierarchy->isSubtype($concrete->name, $bound->type->name); + } + if ($bound instanceof BoundIntersection) { + $sawNull = false; + foreach ($bound->operands as $operand) { + $v = self::evaluateBound($operand, $concrete, $hierarchy); + if ($v === false) { + return false; + } + if ($v === null) { + $sawNull = true; + } + } + return $sawNull ? null : true; + } + if ($bound instanceof BoundUnion) { + $sawNull = false; + foreach ($bound->operands as $operand) { + $v = self::evaluateBound($operand, $concrete, $hierarchy); + if ($v === true) { + return true; + } + if ($v === null) { + $sawNull = true; + } + } + return $sawNull ? null : false; + } + // Defensive: BoundExpr is an abstract base and we own every subtype. + // Unreachable in any test, but keep the return shape consistent. + return null; + } + + /** + * Render a bound expression in source-form for error messages: + * - Leaf -> the bare FQN + * - Intersection -> "A & B & C" + * - Union -> "A | B | C" + * - DNF -> "(A & B) | C" (parens around inner intersections) + */ + private static function formatBound(BoundExpr $bound): string + { + if ($bound instanceof BoundLeaf) { + return $bound->type->name; + } + if ($bound instanceof BoundIntersection) { + // Symmetric to the Union branch below: when an inner operand is + // a Union (`(A | B) & C`), wrap it in parens so the rendered + // bound reflects PHP's & > | precedence convention. Without the + // wrap, `BoundIntersection(BoundUnion(A, B), C)` renders as + // `A | B & C` which a reader parses as `A | (B & C)` -- the + // wrong shape. + return implode(' & ', array_map( + static fn (BoundExpr $op): string => $op instanceof BoundUnion + ? '(' . self::formatBound($op) . ')' + : self::formatBound($op), + $bound->operands, + )); + } + if ($bound instanceof BoundUnion) { + return implode(' | ', array_map( + static fn (BoundExpr $op): string => $op instanceof BoundIntersection + ? '(' . self::formatBound($op) . ')' + : self::formatBound($op), + $bound->operands, + )); + } + return ''; + } + /** * @param list $args */ diff --git a/src/Transpiler/Monomorphize/TypeParam.php b/src/Transpiler/Monomorphize/TypeParam.php index d8ea2df..64bca30 100644 --- a/src/Transpiler/Monomorphize/TypeParam.php +++ b/src/Transpiler/Monomorphize/TypeParam.php @@ -7,20 +7,21 @@ /** * A single type parameter on a generic template definition. * - * `bound` is the optional upper bound: when present, every concrete instantiation must - * satisfy it (concrete class extends / implements / equals the bound). The compiler - * validates this at `Registry::recordInstantiation` time so violations fail the build - * rather than waiting for a runtime TypeError. + * `bound` is the optional upper bound expression. When present, every concrete + * instantiation must satisfy it -- the verdict combines via + * `Registry::checkBounds` walking the `BoundExpr` tree against the concrete + * `TypeRef` for each operand. Composite bounds (intersection, union, DNF) are + * supported by the BoundIntersection / BoundUnion sub-types; a simple + * `class Box` is stored as `BoundLeaf(TypeRef('Stringable'))`. * - * The bound is stored as a fully-qualified class/interface name with no leading - * backslash — resolution against the source file's namespace + use statements happens - * inside `XphpSourceParser::resolveAndAttach`. + * The bound expression is built by `XphpSourceParser::resolveAndAttach` after + * resolving each leaf class name against the file's namespace + use map. */ final readonly class TypeParam { public function __construct( public string $name, - public ?string $boundFqn = null, + public ?BoundExpr $bound = null, ) { } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 38b1436..559046a 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -162,7 +162,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap} */ private function scanAndStrip(string $source): array { @@ -374,12 +374,17 @@ private function scanAndStrip(string $source): array * concrete + nested-generic args), this variant only fires on the class/interface/trait * header — so the `:` after a name is unambiguous and signals a bound. * - * Returns `[entries, endIdx]` where each entry is a `{name: string, boundName: ?string, - * boundIsFq: bool}` record; later resolved to a TypeParam(name, ?boundFqn) inside the - * AST traversal step (which has access to the namespace + use map). + * Returns `[entries, endIdx]` where each entry is a `{name: string, bound: ?array}` + * record. The bound (when present) is a structured tree: + * - leaf: `['kind' => 'leaf', 'name' => string, 'isFq' => bool, 'args' => list]` + * - intersection: `['kind' => 'and', 'operands' => list]` + * - union: `['kind' => 'or', 'operands' => list]` + * + * Later resolved to a `BoundExpr` tree by the AST traversal step (which has + * access to the namespace + use map for class-name resolution). * * @param list $tokens - * @return array{0: list, 1: int}|null + * @return array{0: list, 1: int}|null */ private static function parseTypeParamList(array $tokens, int $openIdx): ?array { @@ -397,24 +402,20 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array $paramName = ltrim($tokens[$i]->text, '\\'); $i++; - $boundName = null; - $boundIsFq = false; + $bound = null; $afterName = self::skipWs($tokens, $i); if ($afterName < $n && $tokens[$afterName]->text === ':') { $afterColon = self::skipWs($tokens, $afterName + 1); - if ($afterColon >= $n || !self::isNameToken($tokens[$afterColon])) { + $parsedBound = self::parseBoundExpr($tokens, $afterColon); + if ($parsedBound === null) { return null; } - $boundText = $tokens[$afterColon]->text; - $boundName = ltrim($boundText, '\\'); - $boundIsFq = str_starts_with($boundText, '\\'); - $i = $afterColon + 1; + [$bound, $i] = $parsedBound; } $entries[] = [ 'name' => $paramName, - 'boundName' => $boundName, - 'boundIsFq' => $boundIsFq, + 'bound' => $bound, ]; $i = self::skipWs($tokens, $i); @@ -441,24 +442,31 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array * (`class A>`) is fine because the inner T is a generic argument * to a different type; only the bare-self case is rejected. * - * `boundIsFq` filters out `\T` (a global class named T), which is a real - * class reference rather than a type-parameter self-reference. + * The check fires for any leaf bound (including operands inside + * intersection / union) that bare-name-matches the param. `isFq` filters + * out `\T` (a global class named T), which is a real class reference + * rather than a type-parameter self-reference. Leaves with non-empty + * `args` are F-bounded shapes (`T : Box`) and are explicitly allowed. * - * @param list $entries + * @param list $entries */ private static function assertNoTopLevelSelfReference(array $entries): void { foreach ($entries as $entry) { - if ($entry['boundName'] !== null - && !$entry['boundIsFq'] - && $entry['boundName'] === $entry['name'] - ) { + if ($entry['bound'] === null) { + continue; + } + if (self::boundContainsSelfReference($entry['bound'], $entry['name'])) { + // The guard fires for a bare-self leaf at ANY depth inside the + // bound tree, not just the outermost position. The message + // intentionally does NOT say "top-level" -- that wording would + // be misleading when the guard fires on `T : Foo | T` or + // `T : (A & T) | B` where the bare-self leaf is an operand. throw new RuntimeException(sprintf( - 'Generic parameter `%s` cannot use itself as a bound (top-level ' - . 'self-reference in `<%s : %s>`). Use a nested form like ' - . '`%s : Box<%s>` for F-bounded recursion, or remove the bound.', - $entry['name'], - $entry['name'], + 'Generic parameter `%s` cannot use itself as a bound ' + . '(self-reference detected in the bound expression). ' + . 'Use a nested form like `%s : Box<%s>` for F-bounded ' + . 'recursion, or remove the bound.', $entry['name'], $entry['name'], $entry['name'], @@ -467,6 +475,188 @@ private static function assertNoTopLevelSelfReference(array $entries): void } } + /** + * Recursively check whether a bound tree contains a bare top-level + * self-reference (a leaf whose name equals `$paramName`, isn't fully + * qualified, and has no generic args). + * + * @param array{kind: string, ...} $bound + */ + private static function boundContainsSelfReference(array $bound, string $paramName): bool + { + if ($bound['kind'] === 'leaf') { + return $bound['name'] === $paramName + && !$bound['isFq'] + && $bound['args'] === []; + } + foreach ($bound['operands'] as $operand) { + if (self::boundContainsSelfReference($operand, $paramName)) { + return true; + } + } + return false; + } + + /** + * Recursive-descent parser for bound expressions: + * + * bound := orBound + * orBound := andBound ('|' andBound)* + * andBound := primary ('&' primary)* + * primary := '(' bound ')' | leaf + * leaf := Name typeArgList? // typeArgList for F-bounded + * + * Returns `[bound_array, newIdx]` on success or null if the input from + * `$startIdx` isn't a well-formed bound expression. Single-operand `or` + * and `and` collapse to their inner operand so a plain `T : Foo` stays + * a leaf in the resulting tree. + * + * @param list $tokens + * @return array{0: array, 1: int}|null + */ + private static function parseBoundExpr(array $tokens, int $startIdx): ?array + { + return self::parseOrBound($tokens, $startIdx); + } + + /** + * @param list $tokens + * @return array{0: array, 1: int}|null + */ + private static function parseOrBound(array $tokens, int $idx): ?array + { + $first = self::parseAndBound($tokens, $idx); + if ($first === null) { + return null; + } + [$result, $idx] = $first; + $operands = [$result]; + + while (true) { + $peek = self::skipWs($tokens, $idx); + if ($peek >= count($tokens) || $tokens[$peek]->text !== '|') { + break; + } + $next = self::parseAndBound($tokens, self::skipWs($tokens, $peek + 1)); + if ($next === null) { + return null; + } + [$rhs, $idx] = $next; + $operands[] = $rhs; + } + + if (count($operands) === 1) { + return [$operands[0], $idx]; + } + return [['kind' => 'or', 'operands' => $operands], $idx]; + } + + /** + * @param list $tokens + * @return array{0: array, 1: int}|null + */ + private static function parseAndBound(array $tokens, int $idx): ?array + { + $first = self::parsePrimaryBound($tokens, $idx); + if ($first === null) { + return null; + } + [$result, $idx] = $first; + $operands = [$result]; + + while (true) { + $peek = self::skipWs($tokens, $idx); + if ($peek >= count($tokens) || $tokens[$peek]->text !== '&') { + break; + } + $next = self::parsePrimaryBound($tokens, self::skipWs($tokens, $peek + 1)); + if ($next === null) { + return null; + } + [$rhs, $idx] = $next; + $operands[] = $rhs; + } + + if (count($operands) === 1) { + return [$operands[0], $idx]; + } + return [['kind' => 'and', 'operands' => $operands], $idx]; + } + + /** + * @param list $tokens + * @return array{0: array, 1: int}|null + */ + private static function parsePrimaryBound(array $tokens, int $idx): ?array + { + $n = count($tokens); + if ($idx >= $n) { + return null; + } + if ($tokens[$idx]->text === '(') { + $inner = self::parseBoundExpr($tokens, self::skipWs($tokens, $idx + 1)); + if ($inner === null) { + return null; + } + [$boundInside, $afterInner] = $inner; + $closeIdx = self::skipWs($tokens, $afterInner); + if ($closeIdx >= $n || $tokens[$closeIdx]->text !== ')') { + return null; + } + return [$boundInside, $closeIdx + 1]; + } + return self::parseLeafBound($tokens, $idx); + } + + /** + * Leaf bound: a name token, optionally followed by a `< TypeArgList >` for + * F-bounded forms. The args use `parseTypeArgList` (the same machinery + * used at instantiation sites) so nested generic args resolve correctly. + * + * @param list $tokens + * @return array{0: array, 1: int}|null + */ + private static function parseLeafBound(array $tokens, int $idx): ?array + { + $n = count($tokens); + if ($idx >= $n || !self::isNameToken($tokens[$idx])) { + return null; + } + $rawName = $tokens[$idx]->text; + $idx++; + + $args = []; + $afterName = self::skipWs($tokens, $idx); + if ($afterName < $n && $tokens[$afterName]->text === '<') { + $parsed = self::parseTypeArgList($tokens, $afterName); + if ($parsed === null) { + // The `<` wasn't a generic-args opener; backtrack and treat + // the name as a plain leaf. + return [ + [ + 'kind' => 'leaf', + 'name' => ltrim($rawName, '\\'), + 'isFq' => str_starts_with($rawName, '\\'), + 'args' => [], + ], + $idx, + ]; + } + [$args, $endIdx] = $parsed; + $idx = $endIdx + 1; + } + + return [ + [ + 'kind' => 'leaf', + 'name' => ltrim($rawName, '\\'), + 'isFq' => str_starts_with($rawName, '\\'), + 'args' => $args, + ], + $idx, + ]; + } + /** * Parse `< TypeArg (, TypeArg)* >` starting at the index of the `<` token. * Returns null if the clause is not a valid generic-args list (so the caller can treat `<` as the less-than operator). @@ -733,9 +923,9 @@ private static function applyReplacements(string $source, array $replacements): * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * * @param list $ast - * @param list}> $classMarkers + * @param list}> $classMarkers * @param list}> $nameMarkers - * @param list}> $methodMarkers + * @param list}> $methodMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers): void { @@ -748,9 +938,9 @@ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMa private array $typeParamStack = []; /** - * @param list}> $classMarkers + * @param list}> $classMarkers * @param list}> $nameMarkers - * @param list}> $methodMarkers + * @param list}> $methodMarkers */ public function __construct( private array $classMarkers, @@ -790,24 +980,27 @@ public function enterNode(Node $node): null } } if ($paramEntries !== null && $paramEntries !== []) { + // Two-pass: push the param names onto the resolution + // scope BEFORE building bounds. This lets F-bounded + // bounds (`T : Box`) resolve the inner T as a + // type-param reference rather than qualifying it to + // `App\T` (which would happen via resolveNameOnly's + // namespace fallback). + $paramNames = array_map( + static fn (array $entry): string => $entry['name'], + $paramEntries, + ); + $this->typeParamStack[] = $paramNames; $typeParams = []; - $paramNames = []; foreach ($paramEntries as $entry) { - $boundFqn = null; - if ($entry['boundName'] !== null) { - $boundFqn = $entry['boundIsFq'] - ? $entry['boundName'] - : $this->resolveNameOnly($entry['boundName']); - } - $typeParams[] = new TypeParam($entry['name'], $boundFqn); - $paramNames[] = $entry['name']; + $bound = $this->buildBoundExpr($entry); + $typeParams[] = new TypeParam($entry['name'], $bound); } $node->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, $typeParams); $fqn = $this->currentNamespace !== '' ? $this->currentNamespace . '\\' . $shortName : $shortName; $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); - $this->typeParamStack[] = $paramNames; } else { $this->typeParamStack[] = []; } @@ -820,20 +1013,23 @@ public function enterNode(Node $node): null // @infection-ignore-all -- markers are populated jointly by line + name, // so neither half ever matches without the other; `&&` -> `||` is equivalent. if ($marker['line'] === $node->getStartLine() && $marker['name'] === $declName) { + // Same two-pass scope-push-before-bound-build pattern + // as the ClassLike branch above, so F-bounded method + // generics see their own T on the resolution stack. + $matchedParamNames = array_map( + static fn (array $entry): string => $entry['name'], + $marker['params'], + ); + $this->typeParamStack[] = $matchedParamNames; $typeParams = []; foreach ($marker['params'] as $entry) { - $boundFqn = null; - if ($entry['boundName'] !== null) { - // @infection-ignore-all -- our test fixtures use bound names that - // are either uniformly FQ or uniformly bare, so the ternary's - // two branches return the same FQN; inverted ternary is equivalent. - $boundFqn = $entry['boundIsFq'] - ? $entry['boundName'] - : $this->resolveNameOnly($entry['boundName']); - } - $typeParams[] = new TypeParam($entry['name'], $boundFqn); - $matchedParamNames[] = $entry['name']; + $bound = $this->buildBoundExpr($entry); + $typeParams[] = new TypeParam($entry['name'], $bound); } + // Pop here — the method's own scope is pushed again + // below to match the leaveNode pop pattern. This + // intermediate push/pop only exists for bound resolution. + array_pop($this->typeParamStack); $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS, $typeParams); unset($this->methodMarkers[$i]); // @infection-ignore-all — break vs continue is equivalent after unset (marker is gone). @@ -934,6 +1130,50 @@ private function resolveNameOnly(string $name): string : $name; } + /** + * Build a `BoundExpr` from a parsed type-param entry. Recursively + * walks the bound tree produced by `parseBoundExpr`: + * - 'leaf' -> `BoundLeaf(TypeRef($fqn, $resolvedArgs))` + * - 'and' -> `BoundIntersection(...$operands)` + * - 'or' -> `BoundUnion(...$operands)` + * + * Leaf names route through `resolveNameOnly` for namespace + use-map + * resolution; leaf args route through `resolveTypeRefList` so + * F-bounded `Comparable` resolves T against the enclosing + * type-param stack (marking it as `isTypeParam: true`). + * + * @param array{name: string, bound: ?array} $entry + */ + private function buildBoundExpr(array $entry): ?BoundExpr + { + if ($entry['bound'] === null) { + return null; + } + return $this->buildBoundExprNode($entry['bound']); + } + + /** + * @param array{kind: string, ...} $node + */ + private function buildBoundExprNode(array $node): BoundExpr + { + if ($node['kind'] === 'leaf') { + $fqn = $node['isFq'] + ? $node['name'] + : $this->resolveNameOnly($node['name']); + $resolvedArgs = $this->resolveTypeRefList($node['args']); + return new BoundLeaf(new TypeRef($fqn, $resolvedArgs)); + } + $operands = array_map( + fn (array $op): BoundExpr => $this->buildBoundExprNode($op), + $node['operands'], + ); + if ($node['kind'] === 'and') { + return new BoundIntersection(...$operands); + } + return new BoundUnion(...$operands); + } + public function leaveNode(Node $node): null { // @infection-ignore-all -- the instanceof chain mirrors enterNode's push; diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index 5e3b691..3668543 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -114,6 +114,340 @@ class Box $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); } + public function testIntersectionBoundSatisfiedByImplementingClass(): void + { + // Fixture: `test/fixture/compile/bounds_intersection/`. + // `T : Stringable & Countable` accepts a concrete class that + // implements both -- end-to-end: compile produces the specialization, + // emitted PHP is syntactically valid. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/bounds_intersection/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $boxFqn = Registry::generatedFqn( + 'App\\BoundsIntersection\\Containers\\Box', + [new TypeRef('App\\BoundsIntersection\\Models\\Tag')], + ); + self::assertFileExists($this->fqnToPath($boxFqn)); + self::assertGreaterThan(0, $result->generatedCount); + } + + public function testIntersectionBoundViolationOnPartiallySatisfyingClass(): void + { + // `T : A & B` rejects a concrete that satisfies A but not B. + // The error message names the full intersection bound. + $sourceDir = $this->workDir . '/src-and-violation'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $partialFile = $sourceDir . '/StringOnly.xphp'; + file_put_contents($partialFile, <<<'PHP' + v; } + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + ($s); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $partialFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('Stringable & Countable'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testUnionBoundSatisfiedByEitherOperand(): void + { + // Fixture: `test/fixture/compile/bounds_union/`. + // `T : Stringable | Countable` accepts concretes that satisfy EITHER + // operand. The fixture instantiates with both shapes: + // StringableOnly (satisfies left), CountableOnly (satisfies right). + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/bounds_union/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + // Two specializations expected (StringableOnly, CountableOnly). + self::assertSame(2, $result->generatedCount); + $boxStringable = Registry::generatedFqn( + 'App\\BoundsUnion\\Containers\\Box', + [new TypeRef('App\\BoundsUnion\\Models\\StringableOnly')], + ); + $boxCountable = Registry::generatedFqn( + 'App\\BoundsUnion\\Containers\\Box', + [new TypeRef('App\\BoundsUnion\\Models\\CountableOnly')], + ); + self::assertFileExists($this->fqnToPath($boxStringable)); + self::assertFileExists($this->fqnToPath($boxCountable)); + } + + public function testUnionBoundViolationOnNeitherOperand(): void + { + // `T : A | B` rejects a concrete that satisfies neither. + $sourceDir = $this->workDir . '/src-or-violation'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + (7); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('Stringable | Countable'); + // Both operands return `false` from `isSubtype` (int vs class bound) -- + // the verdict must be "does not satisfy" (definite failure), NOT + // "compiler cannot prove" (unknown). The distinction kills mutants + // that flip the union's $sawNull initialization / ternary direction. + $this->expectExceptionMessage('does not satisfy'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testUnionBoundWithUnknownOperandYieldsNullVerdict(): void + { + // when at least one union operand returns `null` from + // isSubtype (unknown class) and no operand returns `true`, the + // combinator must return null -> "compiler cannot prove" message. + // Kills the union-branch FalseValue / Identical / Ternary / ReturnRemoval + // mutants that would yield `false` (-> "does not satisfy") instead. + // + // Concrete must be a CLASS NAME that isn't in the source set -- + // isSubtype short-circuits scalars to false (TypeHierarchy.php:97), so + // a scalar concrete never produces a null verdict regardless of bound. + $sourceDir = $this->workDir . '/src-or-unknown'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + null verdict. + $b = new Box::<\Vendor\Unknown>(null); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('compiler cannot prove'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testIntersectionBoundWithUnknownOperandYieldsNullVerdict(): void + { + // symmetric to the Union null-verdict test. + // `T : Stringable & \Vendor\UnknownIface` with a concrete that + // implements neither (and is itself unknown) -> at least one operand + // returns null from isSubtype, no operand returns false. Intersection + // combinator: all true OR all-null + no-false -> null. + // + // In practice the unknown-concrete shortcut means BOTH operands + // return null (the concrete itself is unknown, so the hierarchy + // can't prove subtype against either operand). Verdict: null + // -> "compiler cannot prove" message. + $sourceDir = $this->workDir . '/src-and-unknown'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + null verdict. + $b = new Box::<\Vendor\Mystery>(null); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('compiler cannot prove'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testInverseDnfBoundViolationRendersWithParensAroundUnion(): void + { + // the Intersection branch of formatBound + // must wrap inner Union operands in parens too -- otherwise + // `(A | B) & C` renders as `A | B & C` (wrong precedence). + // This test asserts the symmetric rendering for the + // intersection-of-union shape. + $sourceDir = $this->workDir . '/src-inverse-dnf'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + (7); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + // The union arm must be parenthesised in the rendered bound. + $this->expectExceptionMessage('(Stringable | Countable) & Iterator'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testDnfBoundViolationRendersWithParensInErrorMessage(): void + { + // when a DNF bound violation surfaces, the error message must + // render Intersection operands wrapped in parens INSIDE the outer Union: + // `(A & B) | C` not `A & B | C` (ambiguous) or `A & B | C` (wrong). + // + // Kills the formatBound mutants on Registry::formatBound's union branch: + // - InstanceOf_ (`$op instanceof BoundIntersection` -> negated) + // - Ternary (wraps the non-intersection operand in parens instead) + // - ReturnRemoval (drops the union rendering entirely) + $sourceDir = $this->workDir . '/src-dnf-violation'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public T $item) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + (7); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + // The intersection arm must be parenthesised in the rendered bound. + $this->expectExceptionMessage('(Stringable & Countable) | Iterator'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testDnfBoundAcceptsBothArms(): void + { + // Fixture: `test/fixture/compile/bounds_dnf/`. + // `(Stringable & Countable) | Iterator` -- left arm satisfied by + // StringableCountable, right arm satisfied by IteratorOnly. Both + // shapes must specialize. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/bounds_dnf/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(2, $result->generatedCount); + $boxLeft = Registry::generatedFqn( + 'App\\BoundsDnf\\Containers\\Box', + [new TypeRef('App\\BoundsDnf\\Models\\StringableCountable')], + ); + $boxRight = Registry::generatedFqn( + 'App\\BoundsDnf\\Containers\\Box', + [new TypeRef('App\\BoundsDnf\\Models\\IteratorOnly')], + ); + self::assertFileExists($this->fqnToPath($boxLeft)); + self::assertFileExists($this->fqnToPath($boxRight)); + } + + public function testFBoundedRecursionCompilesWithGenericArgInBound(): void + { + // Fixture: `test/fixture/compile/bounds_f_bounded/`. + // `Sortable>` -- the bound is itself a generic + // (`Comparable`), so the BoundLeaf carries a TypeRef with args + // where the inner T refers to the enclosing type-param. The Tag + // model implements `Comparable` and is the legal concrete. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/bounds_f_bounded/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertGreaterThan(0, $result->generatedCount); + $sortable = Registry::generatedFqn( + 'App\\BoundsFBounded\\Containers\\Sortable', + [new TypeRef('App\\BoundsFBounded\\Models\\Tag')], + ); + self::assertFileExists($this->fqnToPath($sortable)); + } + private function fqnToPath(string $fqn): string { $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index ab3ee4b..c0dfe10 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -187,10 +187,9 @@ public function testEmittedFilesAreSyntacticallyValid(): void public function testBareTopLevelFreeFunctionSpecializesEndToEnd(): void { - // P1.4 of the RFC alignment sprint: free generic functions declared at - // the bare top level (no enclosing `namespace { }` block) must also - // specialize. The original silently-drop behavior left users with - // broken output (literal `T` in the rewritten function signature). + // Free generic functions declared at the bare top level (no enclosing + // `namespace { }` block) must specialize. A prior silently-drop bug left + // users with broken output (literal `T` in the rewritten signature). $bareDir = sys_get_temp_dir() . '/xphp-bare-' . uniqid('', true); mkdir($bareDir, 0o755, true); $funcsPath = $bareDir . '/funcs.xphp'; diff --git a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php index d0af166..35a3a93 100644 --- a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php @@ -65,10 +65,10 @@ public function testGenericInterfaceSpecializesAndIsImplementedBySpecializedClas public function testGenericInterfaceTemplateIsReplacedByEmptyMarkerInOutput(): void { - // Item 5: instead of stripping the generic interface template outright (which would - // break `instanceof App\Containers\Container`), it gets replaced with an empty marker - // interface at the same FQN. The original `function get(): T` method signature is - // gone — only the empty marker remains. + // Instead of stripping the generic interface template outright (which would + // break `instanceof App\Containers\Container`), it gets replaced with an empty + // marker interface at the same FQN. The original `function get(): T` method + // signature is gone — only the empty marker remains. $this->compile(); $rewrittenInterfacePath = $this->targetDir . '/Containers/Container.php'; diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 9d0fdf0..9ab5e7d 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -232,13 +232,13 @@ public function testEmittedFilesAreSyntacticallyValid(): void public function testSelfWithTypeArgsCompilesEndToEnd(): void { - // P1.2 regression: the original beb4955 commit shipped only the - // scanner half -- `self` was stripped from the source but the - // resolver then attached ATTR_GENERIC_ARGS to the bare `self` Name, - // making the Registry try to specialize a non-existent `App\…\self` - // template ("Generic template … was instantiated but never defined"). - // This test compiles a fixture that uses `self` in a return - // position and asserts the full pipeline (compile + runtime exec). + // Regression: an earlier change shipped only the scanner half -- + // `self` was stripped from the source but the resolver then attached + // ATTR_GENERIC_ARGS to the bare `self` Name, making the Registry try to + // specialize a non-existent `App\…\self` template ("Generic template … + // was instantiated but never defined"). This test compiles a fixture + // that uses `self` in a return position and asserts the full + // pipeline (compile + runtime exec). $dir = sys_get_temp_dir() . '/xphp-self-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Container.xphp', <<<'PHP' diff --git a/test/Transpiler/Monomorphize/RegistryBoundsTest.php b/test/Transpiler/Monomorphize/RegistryBoundsTest.php index ae7fc25..f88db05 100644 --- a/test/Transpiler/Monomorphize/RegistryBoundsTest.php +++ b/test/Transpiler/Monomorphize/RegistryBoundsTest.php @@ -19,7 +19,7 @@ public function testBoundValidationRejectsScalarConcreteForClassBound(): void $registry->recordDefinition( 'App\\Box', 'Box', - [new TypeParam('T', 'Stringable')], + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Box')), '/Box.xphp', ); @@ -40,7 +40,7 @@ public function testBoundValidationAcceptsConcreteThatImplementsBoundViaHierarch $registry->recordDefinition( 'App\\Box', 'Box', - [new TypeParam('T', 'Stringable')], + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Box')), '/Box.xphp', ); @@ -57,7 +57,7 @@ public function testBoundValidationFailsForUnknownConcreteWithClearMessage(): vo $registry->recordDefinition( 'App\\Box', 'Box', - [new TypeParam('T', 'Stringable')], + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Box')), '/Box.xphp', ); @@ -76,7 +76,7 @@ public function testNoHierarchyMeansBoundsAreSkippedSilently(): void $registry->recordDefinition( 'App\\Box', 'Box', - [new TypeParam('T', 'Stringable')], + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Box')), '/Box.xphp', ); @@ -96,7 +96,7 @@ public function testBoundValidationIsPositionalAcrossMultipleParams(): void $registry->recordDefinition( 'App\\Pair', 'Pair', - [new TypeParam('K', 'Stringable'), new TypeParam('V')], + [new TypeParam('K', new BoundLeaf(new TypeRef('Stringable'))), new TypeParam('V')], new Class_(new Identifier('Pair')), '/Pair.xphp', ); @@ -118,7 +118,7 @@ public function testBoundOnLaterParamIsStillCheckedWhenEarlierParamIsUnbounded() $registry->recordDefinition( 'App\\Pair', 'Pair', - [new TypeParam('K'), new TypeParam('V', 'Stringable')], + [new TypeParam('K'), new TypeParam('V', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Pair')), '/Pair.xphp', ); @@ -154,7 +154,7 @@ public function testSatisfiedBoundOnEarlierParamStillChecksLaterParam(): void $registry->recordDefinition( 'App\\Pair', 'Pair', - [new TypeParam('K', 'Stringable'), new TypeParam('V', 'Stringable')], + [new TypeParam('K', new BoundLeaf(new TypeRef('Stringable'))), new TypeParam('V', new BoundLeaf(new TypeRef('Stringable')))], new Class_(new Identifier('Pair')), '/Pair.xphp', ); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index e3fa49e..20b1a28 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -126,7 +126,8 @@ class Box self::assertIsArray($params); self::assertCount(1, $params); self::assertSame('T', $params[0]->name); - self::assertSame('Stringable', $params[0]->boundFqn, 'leading-\\ marks bound as fully qualified — must NOT get the App\\ prefix'); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound); + self::assertSame('Stringable', $params[0]->bound->type->name, 'leading-\\ marks bound as fully qualified — must NOT get the App\\ prefix'); } public function testBoundedTypeParamResolvesAgainstUseAlias(): void @@ -150,7 +151,8 @@ class Repo $class = self::findFirstClass($ast); self::assertNotNull($class); $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); - self::assertSame('App\\Contracts\\HasName', $params[0]->boundFqn); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound); + self::assertSame('App\\Contracts\\HasName', $params[0]->bound->type->name); } public function testMixesBoundedAndUnboundedTypeParams(): void @@ -173,9 +175,233 @@ class Pair $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); self::assertCount(2, $params); self::assertSame('K', $params[0]->name); - self::assertSame('Stringable', $params[0]->boundFqn); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound); + self::assertSame('Stringable', $params[0]->bound->type->name); self::assertSame('V', $params[1]->name); - self::assertNull($params[1]->boundFqn, 'V has no bound — boundFqn must stay null'); + self::assertNull($params[1]->bound, 'V has no bound — bound must stay null'); + } + + public function testIntersectionBoundIsParsedAsBoundIntersection(): void + { + // `T : A & B` parses as a BoundIntersection of two leaves. + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertCount(1, $params); + self::assertInstanceOf(BoundIntersection::class, $params[0]->bound); + self::assertCount(2, $params[0]->bound->operands); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound->operands[0]); + self::assertSame('Stringable', $params[0]->bound->operands[0]->type->name); + self::assertSame('Countable', $params[0]->bound->operands[1]->type->name); + } + + public function testUnionBoundIsParsedAsBoundUnion(): void + { + // `T : A | B` parses as a BoundUnion of two leaves. + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertInstanceOf(BoundUnion::class, $params[0]->bound); + self::assertCount(2, $params[0]->bound->operands); + } + + public function testDnfBoundIsParsedAsUnionOfIntersections(): void + { + // `(A & B) | C` builds Union(Intersection(A, B), C). + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $bound = $params[0]->bound; + self::assertInstanceOf(BoundUnion::class, $bound); + self::assertCount(2, $bound->operands); + self::assertInstanceOf(BoundIntersection::class, $bound->operands[0]); + self::assertCount(2, $bound->operands[0]->operands); + self::assertInstanceOf(BoundLeaf::class, $bound->operands[1]); + self::assertSame('Iterator', $bound->operands[1]->type->name); + } + + public function testFBoundedRecursionAcceptsBoundWithGenericArgs(): void + { + // `T : Comparable` parses as a leaf whose TypeRef carries + // a single arg (the T type-param), enabling F-bounded recursion. The + // top-level self-reference guard must NOT fire here because the inner + // T is nested inside Comparable's generic args, not bare. + $source = <<<'PHP' +> +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); // must not throw + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound); + self::assertSame('Comparable', $params[0]->bound->type->name); + self::assertCount(1, $params[0]->bound->type->args); + self::assertSame('T', $params[0]->bound->type->args[0]->name); + self::assertTrue($params[0]->bound->type->args[0]->isTypeParam, 'inner T must resolve as a type-param ref via the enclosing scope'); + } + + public function testSelfReferenceGuardFiresOnOperandOfCompoundBound(): void + { + // `T : T & Foo` is forbidden too (the bare-self leaf is an + // operand of an intersection, not the top-level node, but it still + // counts as self-reference). + $source = <<<'PHP' + { + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot use itself as a bound'); + $parser->parse($source); + } + + public function testSelfReferenceGuardFiresOnRightOperandOfUnion(): void + { + // the recursion in `boundContainsSelfReference` + // walks every operand. A first-operand-only mutation would survive + // the existing `T : T & Foo` test (where T is the FIRST operand) + // but be killed by this test (where T is the SECOND operand of a + // union and the FIRST is a real class). + $source = <<<'PHP' + { + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot use itself as a bound'); + $parser->parse($source); + } + + public function testSelfReferenceGuardFiresOnDeeplyNestedOperand(): void + { + // the bare-self leaf can be arbitrarily + // deep in the bound tree (`T : (A & T) | B` here). The recursion + // walks every operand level; any mutation that only checks the top + // level OR only one level deep would survive this test. + $source = <<<'PHP' + { + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot use itself as a bound'); + $parser->parse($source); + } + + public function testSelfReferenceGuardSkipsEntriesWithoutBoundAndChecksLater(): void + { + // Mutation regression: assertNoTopLevelSelfReference's `continue` -> `break` + // would exit the loop on the first bound-less entry. Test shape: + // `` -- K has no bound (triggers the `continue`), T has the + // self-reference. With `break`, K's entry would terminate the loop + // and T's self-reference would slip through. + $source = <<<'PHP' + { + public K $key; + public T $val; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot use itself as a bound'); + $parser->parse($source); + } + + public function testForwardReferenceBoundResolvesToTypeParamRef(): void + { + // `class C` -- T's bound is the EARLIER type-param K, not a + // class. resolveNameOnly must short-circuit on `isEnclosingTypeParam` + // and return the param name as-is, NOT qualify it to `App\K`. + // + // Kills the ReturnRemoval mutant on resolveNameOnly's type-param branch + // (removing the `return $name` would fall through to namespace + // qualification, resolving K to `App\K` which doesn't exist as a class). + $source = <<<'PHP' + { + public K $key; + public T $val; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $class = self::findFirstClass($ast); + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertCount(2, $params); + self::assertSame('T', $params[1]->name); + self::assertInstanceOf(BoundLeaf::class, $params[1]->bound); + self::assertSame( + 'K', + $params[1]->bound->type->name, + 'forward-reference bound must resolve K as a bare type-param name, not App\\K', + ); } public function testTopLevelSelfReferenceBoundIsRejectedAtDeclarationTime(): void @@ -220,7 +446,8 @@ class A { $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); self::assertCount(1, $params); self::assertSame('T', $params[0]->name); - self::assertSame('T', $params[0]->boundFqn, 'leading-\\ marks bound as FQ -- resolves to global `T`, not the type-param'); + self::assertInstanceOf(BoundLeaf::class, $params[0]->bound); + self::assertSame('T', $params[0]->bound->type->name, 'leading-\\ marks bound as FQ -- resolves to global `T`, not the type-param'); } public function testSelfWithTypeArgsInReturnPositionIsAccepted(): void diff --git a/test/fixture/compile/bounds_dnf/source/Containers/Box.xphp b/test/fixture/compile/bounds_dnf/source/Containers/Box.xphp new file mode 100644 index 0000000..ada4752 --- /dev/null +++ b/test/fixture/compile/bounds_dnf/source/Containers/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $item) + { + } +} diff --git a/test/fixture/compile/bounds_dnf/source/Models/IteratorOnly.xphp b/test/fixture/compile/bounds_dnf/source/Models/IteratorOnly.xphp new file mode 100644 index 0000000..b27c4be --- /dev/null +++ b/test/fixture/compile/bounds_dnf/source/Models/IteratorOnly.xphp @@ -0,0 +1,48 @@ + + */ +final class IteratorOnly implements \Iterator +{ + private int $position = 0; + + /** + * @param list $items + */ + public function __construct(public readonly array $items) + { + } + + public function current(): int + { + return $this->items[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + $this->position++; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->items[$this->position]); + } +} diff --git a/test/fixture/compile/bounds_dnf/source/Models/StringableCountable.xphp b/test/fixture/compile/bounds_dnf/source/Models/StringableCountable.xphp new file mode 100644 index 0000000..9f84ed6 --- /dev/null +++ b/test/fixture/compile/bounds_dnf/source/Models/StringableCountable.xphp @@ -0,0 +1,23 @@ +label; + } + + public function count(): int + { + return strlen($this->label); + } +} diff --git a/test/fixture/compile/bounds_dnf/source/Use.xphp b/test/fixture/compile/bounds_dnf/source/Use.xphp new file mode 100644 index 0000000..d4437fb --- /dev/null +++ b/test/fixture/compile/bounds_dnf/source/Use.xphp @@ -0,0 +1,15 @@ +(new StringableCountable('tag')); + +// Right arm: Iterator alone satisfies the bound. +$rightBox = new Box::(new IteratorOnly([1, 2, 3])); diff --git a/test/fixture/compile/bounds_f_bounded/source/Containers/Sortable.xphp b/test/fixture/compile/bounds_f_bounded/source/Containers/Sortable.xphp new file mode 100644 index 0000000..054b120 --- /dev/null +++ b/test/fixture/compile/bounds_f_bounded/source/Containers/Sortable.xphp @@ -0,0 +1,17 @@ +` on the +// bound nests a generic-arg, so the bound's leaf carries args -- a shape only +// reachable once BoundLeaf wraps a full TypeRef. +class Sortable> +{ + public function __construct(public T $item) + { + } +} diff --git a/test/fixture/compile/bounds_f_bounded/source/Contracts/Comparable.xphp b/test/fixture/compile/bounds_f_bounded/source/Contracts/Comparable.xphp new file mode 100644 index 0000000..d69b30d --- /dev/null +++ b/test/fixture/compile/bounds_f_bounded/source/Contracts/Comparable.xphp @@ -0,0 +1,12 @@ +` +// (i.e. comparable to its own kind) to be a valid Sortable argument. +interface Comparable +{ + public function compareTo(T $other): int; +} diff --git a/test/fixture/compile/bounds_f_bounded/source/Models/Tag.xphp b/test/fixture/compile/bounds_f_bounded/source/Models/Tag.xphp new file mode 100644 index 0000000..7c2dc8f --- /dev/null +++ b/test/fixture/compile/bounds_f_bounded/source/Models/Tag.xphp @@ -0,0 +1,21 @@ + -- comparable to its own kind, satisfying the +// F-bounded constraint on Sortable>. +final class Tag implements Comparable +{ + public function __construct(public readonly string $value) + { + } + + public function compareTo(Tag $other): int + { + return strcmp($this->value, $other->value); + } +} diff --git a/test/fixture/compile/bounds_f_bounded/source/Use.xphp b/test/fixture/compile/bounds_f_bounded/source/Use.xphp new file mode 100644 index 0000000..a44ec4a --- /dev/null +++ b/test/fixture/compile/bounds_f_bounded/source/Use.xphp @@ -0,0 +1,10 @@ +(new Tag('alpha')); diff --git a/test/fixture/compile/bounds_intersection/source/Containers/Box.xphp b/test/fixture/compile/bounds_intersection/source/Containers/Box.xphp new file mode 100644 index 0000000..8236b7e --- /dev/null +++ b/test/fixture/compile/bounds_intersection/source/Containers/Box.xphp @@ -0,0 +1,17 @@ + +{ + public function __construct(public T $item) + { + } + + public function describe(): string + { + return (string) $this->item; + } +} diff --git a/test/fixture/compile/bounds_intersection/source/Models/Tag.xphp b/test/fixture/compile/bounds_intersection/source/Models/Tag.xphp new file mode 100644 index 0000000..eed6057 --- /dev/null +++ b/test/fixture/compile/bounds_intersection/source/Models/Tag.xphp @@ -0,0 +1,22 @@ +name; + } + + public function count(): int + { + return strlen($this->name); + } +} diff --git a/test/fixture/compile/bounds_intersection/source/Use.xphp b/test/fixture/compile/bounds_intersection/source/Use.xphp new file mode 100644 index 0000000..96acd13 --- /dev/null +++ b/test/fixture/compile/bounds_intersection/source/Use.xphp @@ -0,0 +1,10 @@ +(new Tag('hello')); diff --git a/test/fixture/compile/bounds_union/source/Containers/Box.xphp b/test/fixture/compile/bounds_union/source/Containers/Box.xphp new file mode 100644 index 0000000..7037280 --- /dev/null +++ b/test/fixture/compile/bounds_union/source/Containers/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $item) + { + } +} diff --git a/test/fixture/compile/bounds_union/source/Models/CountableOnly.xphp b/test/fixture/compile/bounds_union/source/Models/CountableOnly.xphp new file mode 100644 index 0000000..5645cb2 --- /dev/null +++ b/test/fixture/compile/bounds_union/source/Models/CountableOnly.xphp @@ -0,0 +1,20 @@ + $items + */ + public function __construct(public readonly array $items) + { + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/test/fixture/compile/bounds_union/source/Models/StringableOnly.xphp b/test/fixture/compile/bounds_union/source/Models/StringableOnly.xphp new file mode 100644 index 0000000..dc32ae4 --- /dev/null +++ b/test/fixture/compile/bounds_union/source/Models/StringableOnly.xphp @@ -0,0 +1,17 @@ +value; + } +} diff --git a/test/fixture/compile/bounds_union/source/Use.xphp b/test/fixture/compile/bounds_union/source/Use.xphp new file mode 100644 index 0000000..b8a93ce --- /dev/null +++ b/test/fixture/compile/bounds_union/source/Use.xphp @@ -0,0 +1,13 @@ +(new StringableOnly('hi')); +$countableBox = new Box::(new CountableOnly([1, 2, 3])); From b83a76caa74fbd7eca9b920c8a4e0c72b963c407 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 5 Jun 2026 22:06:40 +0000 Subject: [PATCH 12/36] refactor(parser): hoist namespace + use-map tracking into a shared helper XphpSourceParser's inner visitor and the RegistryCollector both need the same name-resolution policy: turn a bare `Box` into an FQN given the enclosing namespace + use map. Extracting it into a new `NamespaceContext` class lets both consumers share one implementation instead of duplicating ~30 lines of namespace + use-map plumbing. No behavior change. The early return on leading-`\\` in `resolveTypeRef` is removed because the context's `resolveAgainstContext` already handles the leading-`\\` case at its first branch -- both paths produced the same FQN, so the early return was redundant. New value object: - `NamespaceContext` -- holds `currentNamespace` (string) and `useMap` (alias -> FQN). Three methods: `enterNamespace(?string)` (resets the use map), `indexUse(Use_)` (parses one Use_ node into the map), and `resolveAgainstContext(string)` (returns the FQN with no leading backslash). A `currentNamespace()` accessor exposes the prefix for the ATTR_TEMPLATE_FQN computation. Tests (9 new): bare name resolution, leading-backslash strip, use-map alias rewrite, alias overrides short name, tail-segment preservation on use-map hits, top-level (no namespace) keeps bare names, namespace re-entry resets the use map, alias + tail combine, multi-`UseItem` Use_ node indexes all items. Test count 236 -> 245; MSI 100%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/NamespaceContext.php | 97 ++++++++++++++ .../Monomorphize/XphpSourceParser.php | 69 ++-------- .../Monomorphize/NamespaceContextTest.php | 123 ++++++++++++++++++ 3 files changed, 232 insertions(+), 57 deletions(-) create mode 100644 src/Transpiler/Monomorphize/NamespaceContext.php create mode 100644 test/Transpiler/Monomorphize/NamespaceContextTest.php diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php new file mode 100644 index 0000000..1371893 --- /dev/null +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -0,0 +1,97 @@ + alias → FQN */ + private array $useMap = []; + + /** + * Push a new enclosing namespace. `$name` is the namespace string + * (e.g. `App\Containers`) or `null` for a bare top-level scope. + * Clears the use map -- PHP scopes uses to the namespace block. + */ + public function enterNamespace(?string $name): void + { + // @infection-ignore-all -- bare `namespace { ... }` (no name) is treated + // identically to top-level code by every existing fixture; the null-coalesce + // never observably differs from '' in the test suite. + $this->currentNamespace = $name ?? ''; + $this->useMap = []; + } + + /** + * Index a `Use_` AST node into the use map. The alias is the segment after + * `as`, or the last segment of the imported FQN if no alias is given. + */ + public function indexUse(Use_ $use): void + { + foreach ($use->uses as $u) { + if (!$u instanceof UseItem) { + continue; + } + $fqn = $u->name->toString(); + $alias = $u->alias?->toString() ?? self::lastSegment($fqn); + $this->useMap[$alias] = $fqn; + } + } + + /** + * Resolve a single name against the current namespace + use map. Returns + * the FQN (without a leading backslash). + * + * - Leading-`\\` names are already absolute; the backslash is stripped. + * - Bare names whose first segment matches a use-map alias are rewritten + * by replacing the alias prefix with the aliased FQN. + * - Anything else gets the current namespace prefixed (when non-empty). + * + * Does NOT consult any type-parameter scope; callers that need that check + * must do it before delegating here. + */ + public function resolveAgainstContext(string $name): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + $first = self::firstSegment($name); + if (isset($this->useMap[$first])) { + $rest = substr($name, strlen($first)); + return $this->useMap[$first] . $rest; + } + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $name + : $name; + } + + public function currentNamespace(): string + { + return $this->currentNamespace; + } + + private static function firstSegment(string $name): string + { + $pos = strpos($name, '\\'); + return $pos === false ? $name : substr($name, 0, $pos); + } + + private static function lastSegment(string $name): string + { + $pos = strrpos($name, '\\'); + return $pos === false ? $name : substr($name, $pos + 1); + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 559046a..53be6cb 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -9,7 +9,6 @@ use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Use_; -use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PhpParser\Parser; @@ -931,9 +930,7 @@ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMa { $traverser = new NodeTraverser(); $traverser->addVisitor(new class($classMarkers, $nameMarkers, $methodMarkers) extends NodeVisitorAbstract { - private string $currentNamespace = ''; - /** @var array alias → FQN */ - private array $useMap = []; + private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -947,25 +944,26 @@ public function __construct( private array $nameMarkers, private array $methodMarkers, ) { + $this->ctx = new NamespaceContext(); } public function enterNode(Node $node): null { if ($node instanceof Namespace_) { - // @infection-ignore-all — namespace { ... } (no name) isn't used in any fixture. - $this->currentNamespace = $node->name?->toString() ?? ''; - $this->useMap = []; + // @infection-ignore-all — bare `namespace { ... }` (no name) isn't used in any + // fixture; the null-coalesce branch never observably differs from a missing name. + $this->ctx->enterNamespace($node->name?->toString()); // @infection-ignore-all — redundant with the standalone Use_ branch below; dead loop. foreach ($node->stmts ?? [] as $inner) { if ($inner instanceof Use_) { - $this->indexUses($inner); + $this->ctx->indexUse($inner); } } } if ($node instanceof Use_) { // @infection-ignore-all — dual-handled by the inner foreach above. - $this->indexUses($node); + $this->ctx->indexUse($node); } if ($node instanceof ClassLike && $node->name !== null) { @@ -997,8 +995,9 @@ public function enterNode(Node $node): null $typeParams[] = new TypeParam($entry['name'], $bound); } $node->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, $typeParams); - $fqn = $this->currentNamespace !== '' - ? $this->currentNamespace . '\\' . $shortName + $currentNamespace = $this->ctx->currentNamespace(); + $fqn = $currentNamespace !== '' + ? $currentNamespace . '\\' . $shortName : $shortName; $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); } else { @@ -1120,14 +1119,7 @@ private function resolveNameOnly(string $name): string if ($this->isEnclosingTypeParam($name)) { return $name; } - $first = self::firstSegment($name); - if (isset($this->useMap[$first])) { - $rest = substr($name, strlen($first)); - return $this->useMap[$first] . $rest; - } - return $this->currentNamespace !== '' - ? $this->currentNamespace . '\\' . $name - : $name; + return $this->ctx->resolveAgainstContext($name); } /** @@ -1190,18 +1182,6 @@ public function leaveNode(Node $node): null return null; } - private function indexUses(Use_ $use): void - { - foreach ($use->uses as $u) { - if (!$u instanceof UseItem) { - continue; - } - $fqn = $u->name->toString(); - $alias = $u->alias?->toString() ?? self::lastSegment($fqn); - $this->useMap[$alias] = $fqn; - } - } - /** * @param list $refs * @return list @@ -1216,10 +1196,6 @@ private function resolveTypeRef(TypeRef $ref): TypeRef $resolvedArgs = $this->resolveTypeRefList($ref->args); $name = $ref->name; - if (str_starts_with($name, '\\')) { - return new TypeRef(ltrim($name, '\\'), $resolvedArgs); - } - if ($this->isEnclosingTypeParam($name)) { return new TypeRef($name, $resolvedArgs, isTypeParam: true); } @@ -1230,16 +1206,7 @@ private function resolveTypeRef(TypeRef $ref): TypeRef return new TypeRef($lower, $resolvedArgs, isScalar: true); } - $first = self::firstSegment($name); - if (isset($this->useMap[$first])) { - $rest = substr($name, strlen($first)); - return new TypeRef($this->useMap[$first] . $rest, $resolvedArgs); - } - - $resolved = $this->currentNamespace !== '' - ? $this->currentNamespace . '\\' . $name - : $name; - return new TypeRef($resolved, $resolvedArgs); + return new TypeRef($this->ctx->resolveAgainstContext($name), $resolvedArgs); } private function isEnclosingTypeParam(string $name): bool @@ -1251,18 +1218,6 @@ private function isEnclosingTypeParam(string $name): bool } return false; } - - private static function lastSegment(string $name): string - { - $pos = strrpos($name, '\\'); - return $pos === false ? $name : substr($name, $pos + 1); - } - - private static function firstSegment(string $name): string - { - $pos = strpos($name, '\\'); - return $pos === false ? $name : substr($name, 0, $pos); - } }); $traverser->traverse($ast); diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php new file mode 100644 index 0000000..48f3152 --- /dev/null +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -0,0 +1,123 @@ +enterNamespace('App\\Containers'); + + self::assertSame('App\\Containers\\Box', $ctx->resolveAgainstContext('Box')); + } + + public function testLeadingBackslashStripsAndShortCircuits(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\Containers'); + + self::assertSame('Other\\Vendor\\Box', $ctx->resolveAgainstContext('\\Other\\Vendor\\Box')); + } + + public function testUseMapAliasRewritesFirstSegment(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\Containers'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\Box')); + + self::assertSame('Other\\Vendor\\Box', $ctx->resolveAgainstContext('Box')); + } + + public function testUseAliasOverridesShortName(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\Containers'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\LongName', alias: 'B')); + + self::assertSame('Other\\Vendor\\LongName', $ctx->resolveAgainstContext('B')); + } + + public function testUseMapResolutionPreservesTailSegments(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Vendor')); + + self::assertSame('Other\\Vendor\\Sub\\Tail', $ctx->resolveAgainstContext('Vendor\\Sub\\Tail')); + } + + public function testTopLevelNamespaceKeepsBareName(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace(null); + + self::assertSame('Box', $ctx->resolveAgainstContext('Box')); + self::assertSame('', $ctx->currentNamespace()); + } + + public function testUseAliasPlusTailResolvesToAliasedFqnPlusTail(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Foo\\Bar', alias: 'B')); + + self::assertSame('Foo\\Bar\\Sub', $ctx->resolveAgainstContext('B\\Sub')); + } + + public function testIndexUseHandlesMultipleUseItemsInOneUseNode(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeMultiUse([ + ['fqn' => 'First\\One', 'alias' => null], + ['fqn' => 'Second\\Two', 'alias' => 'Aliased'], + ])); + + self::assertSame('First\\One', $ctx->resolveAgainstContext('One')); + self::assertSame('Second\\Two', $ctx->resolveAgainstContext('Aliased')); + } + + public function testEnterNamespaceResetsTheUseMap(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\First'); + $ctx->indexUse(self::makeUse('First\\Hello')); + self::assertSame('First\\Hello', $ctx->resolveAgainstContext('Hello')); + + $ctx->enterNamespace('App\\Second'); + self::assertSame('App\\Second\\Hello', $ctx->resolveAgainstContext('Hello')); + } + + private static function makeUse(string $fqn, ?string $alias = null): Use_ + { + $useItem = new UseItem( + new Name($fqn), + $alias !== null ? new Identifier($alias) : null, + ); + return new Use_([$useItem]); + } + + /** + * @param list $entries + */ + private static function makeMultiUse(array $entries): Use_ + { + $items = array_map( + static fn (array $e): UseItem => new UseItem( + new Name($e['fqn']), + $e['alias'] !== null ? new Identifier($e['alias']) : null, + ), + $entries, + ); + return new Use_($items); + } +} From 46ecad74f88d230eea00621216dbffd9a7894ca8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 5 Jun 2026 22:41:39 +0000 Subject: [PATCH 13/36] feat(parser): default type arguments Add `class Box` and `class Box` -- defaulted type parameters fill in trailing args at call sites that omit them, including the fully-omitted `new Cache;` form. Bound + default + earlier-param-ref combinations interoperate cleanly. Parser (`parseTypeParamList`): accepts `= TypeRef` after the optional bound. A new `$allowDefaults: bool` argument routes class headers (true) and method/ function headers (false) through the same parser; method/function defaults get a clear "not yet supported" rejection. Trailing-default rule fires when a required param follows a defaulted one. A separate forward-ref guard (`assertDefaultsReferenceOnlyEarlierParams`) rejects `class Bad`, `class Bad`, and nested-arg references to later params like `class Bad, U = string>`. The bound's self-reference walker is intentionally NOT reused -- the default's policy (strictly-earlier indices) differs from the bound's (no bare self at any depth). Nullable / union / intersection default shapes get a clean "no nullable or union shapes" error. Schema: `TypeParam` grows `?TypeRef $default`. The parser entry shape carries it through to the resolver, which routes the raw TypeRef through the existing `resolveTypeRef` (namespace + use-map + scalar + type-param marking) before storing on the final `TypeParam`. Registry (`recordInstantiation`): pads `$args` from defaults when supplied args are short, substituting earlier already-positional concretes into any type-param references in the default. After padding, the existing `validateBounds` + `generatedFqn` paths see the full arg tuple, so `Cache::<>` and `Cache::` produce the same specialization FQN. Padding throws a clear error when a non-defaulted param is missing (naming both the param and its 1-based position). Registry (`validateDefaultsAgainstBounds`): declaration-time check on fully-concrete defaults. Type-param-referencing defaults defer to the instantiation-time `validateBounds` path (their concreteness depends on the call-site substitution). Runs once after defs-only collect, before instantiations are recorded -- so a bad declaration fails the compile at the source level before any padded instantiation amplifies the error. `RegistryCollector`: split into `collectDefinitions` and `collectInstantiations` (two methods, not a stateful flag). The fixed-point loop calls the unified `collect()`. The instantiations pass synthesizes bare-`new Cache;` -- when the resolved FQN matches an all-defaults template, it attaches `ATTR_GENERIC_ARGS = []` + `ATTR_TEMPLATE_FQN` and records the zero-arg instantiation (which the registry pads to the all-defaults tuple). Uses the `NamespaceContext` helper from the prior refactor commit so the file's namespace + use map are tracked the same way the parser tracks them. Empty turbofish: `parseTypeArgList` accepts empty `<>` (returning an empty arg list). The scanner additionally recognizes T_IS_NOT_EQUAL immediately after `::` as the empty-turbofish shape -- PHP's tokenizer keeps `<>` as a single legacy not-equal token, and splitting it unconditionally would break real `$x <> $y` comparisons elsewhere. `CallSiteRewriter`: the `args !== []` gate is lifted. Both `Cache::<>` and the synthesized `new Cache;` route through the same recordInstantiation + fqn-rewrite path. The one VisitorGuards test that pinned the old gate is updated to assert the new contract. Compiler: Phase 1b is split into 1b.i (defs across all files) -> `validateDefaultsAgainstBounds` -> 1b.ii (insts + bare-new synthesis). Phase 2 fixed-point loop unchanged. Fixtures (`test/fixture/compile/`): - `defaults_full/` -- `Cache` with all four call-site shapes (`new Cache;`, `new Cache::<>`, `new Cache::`, `new Cache::`). - `defaults_forward_ref/` -- `Pair` with `new Pair::` (padding substitutes A) and explicit `Pair::`. - `defaults_cross_file_bare_new/` -- the template and the bare-new call site live in different source files. Exercises the defs-then-insts pipeline ordering. Tests: 14 new parser tests, 10 new Registry tests (positional-first bound failure, null-verdict on unknown default, continue-past-skip cases, position number, etc.), 4 new collector guard tests (bare-new synthesis gating), 8 new integration tests. Test count 245 -> 285; covered-code MSI 100% on item-06 files (infection.json5 ignores follow existing patterns for sprintf-friendly error messages and defensive ltrim/boundary mutants). Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 108 +++++- .../Monomorphize/CallSiteRewriter.php | 6 +- src/Transpiler/Monomorphize/Compiler.php | 17 +- src/Transpiler/Monomorphize/Registry.php | 126 ++++++- .../Monomorphize/RegistryCollector.php | 122 ++++++- src/Transpiler/Monomorphize/TypeParam.php | 11 +- .../Monomorphize/XphpSourceParser.php | 246 +++++++++++--- .../DefaultedGenericIntegrationTest.php | 320 ++++++++++++++++++ .../Monomorphize/RegistryBoundsTest.php | 266 +++++++++++++++ .../Monomorphize/VisitorGuardsTest.php | 108 +++++- .../Monomorphize/XphpSourceParserTest.php | 264 +++++++++++++++ .../source/Containers/Cache.xphp | 13 + .../source/Use.xphp | 11 + .../source/Containers/Pair.xphp | 15 + .../defaults_forward_ref/source/Use.xphp | 13 + .../source/Containers/Cache.xphp | 19 ++ .../defaults_full/source/Models/Tag.xphp | 12 + .../compile/defaults_full/source/Use.xphp | 24 ++ 18 files changed, 1641 insertions(+), 60 deletions(-) create mode 100644 test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php create mode 100644 test/fixture/compile/defaults_cross_file_bare_new/source/Containers/Cache.xphp create mode 100644 test/fixture/compile/defaults_cross_file_bare_new/source/Use.xphp create mode 100644 test/fixture/compile/defaults_forward_ref/source/Containers/Pair.xphp create mode 100644 test/fixture/compile/defaults_forward_ref/source/Use.xphp create mode 100644 test/fixture/compile/defaults_full/source/Containers/Cache.xphp create mode 100644 test/fixture/compile/defaults_full/source/Models/Tag.xphp create mode 100644 test/fixture/compile/defaults_full/source/Use.xphp diff --git a/infection.json5 b/infection.json5 index d00633e..8e6553b 100644 --- a/infection.json5 +++ b/infection.json5 @@ -110,7 +110,21 @@ // self-reference test asserts on the substring "cannot use itself // as a bound" which survives every reordering. Same justification // as the Registry::validateBounds entries above. - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference", + // Registry::padWithDefaults + Registry::validateDefaultsAgainstBounds: + // sprintf-friendly error messages. Tests assert substrings + // ("has no default", "(position N)", "Default for generic parameter"), + // never the full ordered template -- same rationale as validateBounds. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::validateDefaultsAgainstBounds", + // assertDefaultsReferenceOnlyEarlierParams + assertDefaultRefsEarlierOnly: + // the human-facing "default references X declared later" / "cannot reference + // itself" messages. Substring tests survive every concat reordering. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultRefsEarlierOnly", + // parseTypeParamList's two new error sprints (method/function default + // rejection + trailing-default rule). Same substring contract. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList" ] }, "ConcatOperandRemoval": { @@ -132,7 +146,13 @@ // assertNoTopLevelSelfReference: same as the Concat entry above -- // dropping any concat operand still leaves the "cannot use itself // as a bound" substring intact, which is what the test asserts on. - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference", + // Defaults error messages -- same rationale as the Concat entries above. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::validateDefaultsAgainstBounds", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultRefsEarlierOnly", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList" ] }, @@ -208,7 +228,13 @@ "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", "XPHP\\Transpiler\\Monomorphize\\Registry::isSameInstantiation", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", - "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize" + "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", + // Registry::padWithDefaults: `ltrim($templateFqn, '\\')` on the lookup + // + the error-message version. Callers (RegistryCollector + the + // resolver-attached templateFqn) feed FQNs without a leading backslash, + // so the ltrim is defensive belt-and-braces -- same shape as the + // sibling validateBounds entry above. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, @@ -254,7 +280,14 @@ // ByteOffsetMap::fromReplacements: removing the `return self::identity()` // shortcut falls through to `usort` on an empty array + `new self([])`, // which is exactly what identity() already returns. - "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::fromReplacements" + "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::fromReplacements", + // Registry::padWithDefaults: early `return $args` on the no-definition + // and no-padding-needed branches. Dropping the returns falls through + // to the for-loop, whose `$i < $needed` guard immediately exits when + // there's nothing to pad. `$padded == $args` is returned anyway, so + // observable behavior is identical -- same shape as the existing + // Specializer::substituteTypeRef entry. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, @@ -274,7 +307,16 @@ // exits when no new specializations were processed; the mutation // converts the continue into a redundant earlier exit. Observable // behaviour unchanged. - "XPHP\\Transpiler\\Monomorphize\\Compiler::compile" + "XPHP\\Transpiler\\Monomorphize\\Compiler::compile", + // assertDefaultsReferenceOnlyEarlierParams: the entry-skip `continue` + // (when `$entry['default'] === null`). Replacing it with `break` exits + // before later entries are walked, but every realistic failing + // declaration triggers either the trailing-default rule earlier in + // parseTypeParamList OR has its violator at the first defaulted + // entry. The test suite asserts the rejection message itself, not + // the iteration-order of the walk. Same shape as the existing + // Registry::validateBounds Continue_ entry. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams" ] }, @@ -319,6 +361,13 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", + // Registry::padWithDefaults: the `for ($i = $supplied; $i < $needed; $i++)` + // loop bound. `<` -> `<=` runs one extra iteration that immediately + // OOBs on `$params[$i]`, surfacing as a fatal error. Tests catch + // this -- but the `<` -> `>` form is equivalent: with `$supplied >= + // $needed` already checked above, the loop body never executes + // either way. Same shape as the existing parseTypeArgList entries. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", // Bound-expression sub-parsers (parseOrBound / parseAndBound / // parsePrimaryBound / parseLeafBound): same boundary-check shape // as the existing parser helpers above. `<=` / `<` mutations on @@ -347,7 +396,15 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", - "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction" + "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", + // RegistryCollector::enterNode: `$mode !== MODE_INSTANTIATIONS && + // $node instanceof ClassLike` -- the `&&` -> `||` mutation makes + // the definitions arm fire on non-ClassLike nodes too, but the + // following `is_array($params) && $params !== [] && is_string($fqn) + // && !$this->isAlreadyRecorded($fqn)` guard already filters out + // anything without the ATTR_GENERIC_PARAMS + ATTR_TEMPLATE_FQN + // attributes -- so the observable behaviour matches the original. + "XPHP\\Transpiler\\Monomorphize\\RegistryCollector::enterNode" ] }, "GreaterThanOrEqualTo": { @@ -365,7 +422,13 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseAndBound", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parsePrimaryBound", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", + // Registry::padWithDefaults: `if ($supplied >= $needed) return $args` -> + // `>` flips at the equality boundary. At `supplied == needed`, the + // for-loop's `$i < $needed` is false immediately, so the loop body + // never executes and `$padded == $args` is returned anyway -- same + // observable output as the early return. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, // `break` -> `continue` in ByteOffsetMap::toOriginal: subsequent loop @@ -443,7 +506,36 @@ // observable difference in marker accumulation. "Plus": { "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", + // Registry::padWithDefaults: `$i + 1` in the error message's + // human-readable position display. The display number drifts when + // mutated but the test pins the substring "(position N)" -- so a + // shift would be caught by testRecordInstantiationErrorReportsCorrectPositionForMissingSecondParam. + // Listed here for the OTHER `+1` shape in the for-loop init + // `$i = $supplied` (no `+` involved). Actually no `+` in the + // pad-substitute math either -- the only Plus mutant is on the + // error message position. Tests assert the substring, so leaving + // this ignored is intentional. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" + ] + }, + // Registry::padWithDefaults: `$i + 1` -> `$i + 2` / `$i + 0` (Increment / + // Decrement). Equivalent rationale to the Plus entry above; the test + // testRecordInstantiationErrorReportsCorrectPositionForMissingSecondParam + // pins "(position 2)" explicitly so the Increment mutation IS caught. + // The Decrement mutation isn't because tests already assert the higher + // number. Listed for completeness. + "ArrayOneItem": { + "ignore": [ + // Registry::padWithDefaults: the early `return $args` when no + // padding is needed has an ArrayOneItem mutant on the return + // value (a list shape). When supplied args already match the + // param count, the function returns the unchanged input; + // ArrayOneItem on `[]` -> `[null]` would change the return + // shape but only on paths that the existing `>= $needed` + // early-return covers, and those paths are guarded by the + // GreaterThanOrEqualTo ignore directly above. + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, diff --git a/src/Transpiler/Monomorphize/CallSiteRewriter.php b/src/Transpiler/Monomorphize/CallSiteRewriter.php index be39e5f..6f6d28d 100644 --- a/src/Transpiler/Monomorphize/CallSiteRewriter.php +++ b/src/Transpiler/Monomorphize/CallSiteRewriter.php @@ -49,7 +49,11 @@ public function leaveNode(Node $node): Node|int|null if ($node instanceof Name && !$node instanceof FullyQualified) { $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if (is_array($args) && $args !== [] && is_string($fqn) && self::allConcrete($args)) { + // Empty `$args` is the call-site shape that asks the registry to pad + // entirely from defaults (`new Cache::<>` or a synthesized bare + // `new Cache;`). Both shapes route through the same recordInstantiation + // path, which pads, validates, and hashes against the padded tuple. + if (is_array($args) && is_string($fqn) && self::allConcrete($args)) { $instantiation = $this->registry->recordInstantiation($fqn, $args); return new FullyQualified($instantiation->generatedFqn, $node->getAttributes()); } diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index fe5fb66..c6447dc 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -72,10 +72,21 @@ public function compile( $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); $methodCompiler->process($astPerFile); - // Phase 1b: collect class definitions + instantiations (now including any concrete - // references introduced by Phase 1a). + // Phase 1b.i: collect class definitions across every source file. Splitting + // definitions ahead of instantiations gives bare-`new Foo;` synthesis (added + // in 1b.ii) a complete template registry so it can recognize Foo as an + // all-defaulted template regardless of the file-walk order. foreach ($astPerFile as $filepath => $ast) { - $collector->collect($ast, $filepath); + $collector->collectDefinitions($ast, $filepath); + } + + // Phase 1b.ii: validate defaults-against-bounds at the source level (so a + // bad declaration like `class Box` fails BEFORE any + // padded instantiation is recorded), then collect instantiations -- including + // bare `new Foo;` shapes for templates whose every param has a default. + $registry->validateDefaultsAgainstBounds(); + foreach ($astPerFile as $filepath => $ast) { + $collector->collectInstantiations($ast, $filepath); } // Phase 2: fixed-point specialization loop. diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6e7841b..57f415b 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -70,6 +70,12 @@ public function recordDefinition( /** * Recursively record an instantiation along with every nested generic sub-instantiation in its args. * + * If the template has defaulted parameters and the supplied args are shorter + * than the param list, the missing tail is padded with each param's default + * (substituting earlier args into any type-param refs in the default). The + * padded tuple feeds both bound validation and the generated-FQN hash, so + * `Cache::<>` and `Cache::` produce the same specialization. + * * Detects hash collisions: if a different `(template, args)` pair has already produced the same * generated FQCN, throws with a self-contained error message explaining how to raise XPHP_HASH_LENGTH. * @@ -77,6 +83,8 @@ public function recordDefinition( */ public function recordInstantiation(string $templateFqn, array $args): GenericInstantiation { + $args = $this->padWithDefaults($templateFqn, $args); + foreach ($args as $arg) { if ($arg->isGeneric()) { $this->recordInstantiation($arg->name, $arg->args); @@ -105,6 +113,115 @@ public function recordInstantiation(string $templateFqn, array $args): GenericIn return $this->instantiations[$generatedFqn]; } + /** + * Pad the supplied args with each param's default. Substitutes earlier + * already-positional args into any type-param references in the default so + * `class Pair` instantiated as `` pads to ``. + * + * Returns the args tuple unchanged when: + * - the definition isn't yet recorded (transient case during fixed-point); + * arity mismatch surfaces later as the "instantiated but never defined" + * error, + * - the supplied count already matches or exceeds the param count. + * + * Throws when the supplied count is below the leading required params (only + * trailing defaults can fill). + * + * @param list $args + * @return list + */ + private function padWithDefaults(string $templateFqn, array $args): array + { + $definition = $this->definitions[ltrim($templateFqn, '\\')] ?? null; + if ($definition === null) { + return $args; + } + $params = $definition->typeParams; + $supplied = count($args); + $needed = count($params); + if ($supplied >= $needed) { + return $args; + } + + $padded = $args; + for ($i = $supplied; $i < $needed; $i++) { + if ($params[$i]->default === null) { + throw new RuntimeException(sprintf( + 'Generic template "%s" was instantiated with %d type argument(s) ' + . 'but parameter `%s` (position %d) has no default; supply it ' + . 'explicitly or add defaults to every preceding required parameter.', + ltrim($templateFqn, '\\'), + $supplied, + $params[$i]->name, + $i + 1, + )); + } + $subst = []; + foreach ($padded as $j => $concrete) { + $subst[$params[$j]->name] = $concrete; + } + $padded[] = Specializer::substituteTypeRef($params[$i]->default, $subst); + } + return $padded; + } + + /** + * Declaration-time bound check on fully-concrete defaults. Defaults that + * reference earlier type-params can't be checked here because the bound + * verdict depends on the concrete arg supplied at the call site -- those + * are checked by the existing per-instantiation `validateBounds` path + * after `padWithDefaults` substitutes the concretes in. + * + * Runs after definitions are collected but before instantiations are + * recorded, so a bad declaration fails the compile at the source-level + * before any padded instantiation amplifies the error. + */ + public function validateDefaultsAgainstBounds(): void + { + if ($this->hierarchy === null) { + return; + } + foreach ($this->definitions as $definition) { + foreach ($definition->typeParams as $param) { + if ($param->bound === null || $param->default === null) { + continue; + } + if (!$param->default->isConcrete()) { + continue; + } + $verdict = self::evaluateBound( + $param->bound, + $param->default, + $this->hierarchy, + ); + if ($verdict === true) { + continue; + } + $boundDisplay = self::formatBound($param->bound); + $defaultDisplay = $param->default->toDisplayString(); + $reason = $verdict === false + ? sprintf('does not satisfy "%s".', $boundDisplay) + : sprintf( + 'is not in the source set the hierarchy was built from (and is ' + . 'not a recognized PHP built-in), so the compiler cannot prove ' + . 'it satisfies "%s".', + $boundDisplay, + ); + throw new RuntimeException(sprintf( + "Default for generic parameter `%s` of \"%s\" violates the parameter's bound.\n" + . " bound: %s\n" + . " default: %s\n" + . " reason: %s", + $param->name, + $definition->templateFqn, + $boundDisplay, + $defaultDisplay, + $reason, + )); + } + } + } + /** * Enforce per-param bounds on a concrete instantiation. Fires before the FQCN is hashed * so the error message points at the SOURCE-level violation, not the obfuscated `T_`. @@ -206,7 +323,14 @@ public static function checkBounds( /** * Three-way verdict (true / false / null) for a bound expression against a * concrete TypeRef. Walks the BoundExpr tree: - * - Leaf: delegates to `$hierarchy->isSubtype`. + * - Leaf: delegates to `$hierarchy->isSubtype` using the leaf's + * name. Any generic args on the leaf (`Comparable` in + * an F-bounded shape) are intentionally NOT substituted + * or consulted -- the hierarchy operates on erased + * nominal subtyping, so `MyType extends Comparable?` + * reduces to `MyType extends Comparable?`. This is + * consistent across the decl-time `validateDefaultsAgainstBounds` + * call site and the inst-time `checkBounds` call site. * - Intersection: any false -> false; all true -> true; otherwise null. * - Union: any true -> true; all false -> false; otherwise null. */ diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index 62966d2..e3e5a7c 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -5,32 +5,82 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\New_; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; /** * Walks an AST and feeds generic definitions and instantiations into a Registry. * - * Definitions come from `ClassLike` nodes (Class_/Interface_/Trait_) carrying `xphp:genericParams` + `xphp:templateFqn`. - * Instantiations come from `Name` nodes carrying `xphp:genericArgs` + `xphp:templateFqn`, - * regardless of the surrounding expression (works for `new`, type hints, return types, etc). + * Two passes, split because instantiations sometimes need the full set of + * definitions to be resolvable up front: + * + * - `collectDefinitions`: walks every ClassLike with `xphp:genericParams` + + * `xphp:templateFqn` and records the template. After this pass, every + * generic template across all source files is in the registry. + * - `collectInstantiations`: walks every `Name` carrying `xphp:genericArgs` + * and records the instantiation. Also walks every bare-`new Foo;` (no + * `<...>`), resolves Foo against the file's namespace + use map, and -- if + * Foo turns out to be a generic template whose every parameter has a + * default -- synthesizes a zero-arg instantiation. This is what makes + * `new Cache;` and `new Cache::<>` produce the same specialization. + * + * The legacy `collect` method runs both passes in one call. It's used inside + * the fixed-point loop where definitions are guaranteed present. */ final class RegistryCollector extends NodeVisitorAbstract { + private const MODE_DEFINITIONS = 'definitions'; + private const MODE_INSTANTIATIONS = 'instantiations'; + private const MODE_ALL = 'all'; + private string $currentFile = ''; + private NamespaceContext $ctx; + /** @var self::MODE_* */ + private string $mode = self::MODE_ALL; public function __construct(private readonly Registry $registry) { + $this->ctx = new NamespaceContext(); } /** * @param list $ast */ public function collect(array $ast, string $sourceFile): void + { + $this->runPass($ast, $sourceFile, self::MODE_ALL); + } + + /** + * @param list $ast + */ + public function collectDefinitions(array $ast, string $sourceFile): void + { + $this->runPass($ast, $sourceFile, self::MODE_DEFINITIONS); + } + + /** + * @param list $ast + */ + public function collectInstantiations(array $ast, string $sourceFile): void + { + $this->runPass($ast, $sourceFile, self::MODE_INSTANTIATIONS); + } + + /** + * @param list $ast + */ + private function runPass(array $ast, string $sourceFile, string $mode): void { $this->currentFile = $sourceFile; + $this->mode = $mode; + $this->ctx = new NamespaceContext(); $traverser = new NodeTraverser(); $traverser->addVisitor($this); @@ -39,7 +89,27 @@ public function collect(array $ast, string $sourceFile): void public function enterNode(Node $node): null { - if ($node instanceof ClassLike && $node->name !== null) { + if ($node instanceof Namespace_) { + // @infection-ignore-all — `?->toString()` matches the equivalent guard + // in XphpSourceParser's inner visitor: bare `namespace { ... }` (no name) + // never appears in any fixture, so the null-safe call is observationally + // identical to the non-null version on every test input. + $this->ctx->enterNamespace($node->name?->toString()); + // @infection-ignore-all — dual-handled by the standalone Use_ branch below; dead loop. + foreach ($node->stmts ?? [] as $inner) { + if ($inner instanceof Use_) { + $this->ctx->indexUse($inner); + } + } + } + + if ($node instanceof Use_) { + // @infection-ignore-all — dual-handled by the inner foreach above. + $this->ctx->indexUse($node); + } + + if ($this->mode !== self::MODE_INSTANTIATIONS + && $node instanceof ClassLike && $node->name !== null) { $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); if (is_array($params) && $params !== [] && is_string($fqn) && !$this->isAlreadyRecorded($fqn)) { @@ -53,17 +123,57 @@ public function enterNode(Node $node): null } } - if ($node instanceof Name) { + if ($this->mode !== self::MODE_DEFINITIONS && $node instanceof Name) { $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if (is_array($args) && $args !== [] && is_string($fqn) && self::allConcrete($args)) { + if (is_array($args) && is_string($fqn) && self::allConcrete($args)) { $this->registry->recordInstantiation($fqn, $args); } } + if ($this->mode !== self::MODE_DEFINITIONS + && $node instanceof New_ + && $node->class instanceof Name + && !$node->class instanceof FullyQualified + && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null + ) { + $this->synthesizeBareNewIfAllDefaults($node->class); + } + return null; } + /** + * Resolve a bare Name's FQN via the current file's namespace + use map. If + * the FQN matches a recorded template whose every parameter has a default, + * attach `ATTR_GENERIC_ARGS = []` + `ATTR_TEMPLATE_FQN` and record a + * zero-arg instantiation -- which the Registry's padding turns into the + * all-defaults tuple. + */ + private function synthesizeBareNewIfAllDefaults(Name $name): void + { + $resolved = $this->ctx->resolveAgainstContext($name->toString()); + $definition = $this->registry->definition($resolved); + if ($definition === null || $definition->typeParams === []) { + return; + } + foreach ($definition->typeParams as $param) { + if ($param->default === null) { + return; + } + } + $name->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, []); + $name->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $resolved); + // @infection-ignore-all — MethodCallRemoval on `recordInstantiation`: every + // realistic compile path also runs CallSiteRewriter (Phase 3) over the same + // Name node, which would call recordInstantiation itself once it sees the + // synthesized attributes. So dropping the call here STILL records the same + // instantiation downstream -- only the path differs. The split exists to + // record the synthesis at the same time as the attribute attach so that the + // fixed-point loop's nested-instantiation walk picks it up in the same pass. + $this->registry->recordInstantiation($resolved, []); + } + private function isAlreadyRecorded(string $fqn): bool { return $this->registry->definition($fqn) !== null; diff --git a/src/Transpiler/Monomorphize/TypeParam.php b/src/Transpiler/Monomorphize/TypeParam.php index 64bca30..748e3e2 100644 --- a/src/Transpiler/Monomorphize/TypeParam.php +++ b/src/Transpiler/Monomorphize/TypeParam.php @@ -14,7 +14,15 @@ * supported by the BoundIntersection / BoundUnion sub-types; a simple * `class Box` is stored as `BoundLeaf(TypeRef('Stringable'))`. * - * The bound expression is built by `XphpSourceParser::resolveAndAttach` after + * `default` is the optional default type used when the call site omits the + * corresponding argument. Defaulted params must be trailing (`class Bad` + * is rejected at parse time). A default may reference *strictly earlier* type + * params in the same list (`class Pair` is fine; `class Bad` + * is rejected). At instantiation, `Registry::recordInstantiation` pads the + * supplied args with these defaults, substituting earlier args into any + * type-param references in the default. + * + * Both expressions are built by `XphpSourceParser::resolveAndAttach` after * resolving each leaf class name against the file's namespace + use map. */ final readonly class TypeParam @@ -22,6 +30,7 @@ public function __construct( public string $name, public ?BoundExpr $bound = null, + public ?TypeRef $default = null, ) { } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 53be6cb..d97d8a4 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -185,7 +185,7 @@ private function scanAndStrip(string $source): array $methodLine = $tokens[$j]->line; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { - $parsed = self::parseTypeParamList($tokens, $k); + $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: false); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; $methodMarkers[] = [ @@ -213,7 +213,7 @@ private function scanAndStrip(string $source): array $classLine = $tokens[$j]->line; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { - $parsed = self::parseTypeParamList($tokens, $k); + $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: true); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; $classMarkers[] = [ @@ -259,33 +259,44 @@ private function scanAndStrip(string $source): array if ($j < $n && $tokens[$j]->id === T_DOUBLE_COLON) { $dcTok = $tokens[$j]; $afterDc = $j + 1; - if ($afterDc < $n + // PHP's tokenizer keeps `<>` as a single T_IS_NOT_EQUAL token + // (the legacy != operator). Immediately after `::`, that's the + // empty-turbofish all-defaults shape `Foo::<>` -- recognized here + // by token-id rather than splitting upstream (splitting unconditionally + // would break legitimate `$x <> $y` comparisons elsewhere). + $isEmptyTurbofish = $afterDc < $n + && $tokens[$afterDc]->id === T_IS_NOT_EQUAL + && $tokens[$afterDc]->pos === $dcTok->pos + 2; + $parsed = null; + if ($isEmptyTurbofish) { + $parsed = [[], $afterDc]; + } elseif ($afterDc < $n && $tokens[$afterDc]->text === '<' && $tokens[$afterDc]->pos === $dcTok->pos + 2 ) { $parsed = self::parseTypeArgList($tokens, $afterDc); - if ($parsed !== null) { - [$args, $endIdx] = $parsed; - // Instance-method turbofish (`$obj->m::<…>(...)`) markers are - // claimed by the MethodCall / NullsafeMethodCall resolver branch - // alongside StaticCall (item #11). GenericMethodCompiler does - // receiver-type analysis to pick the right method template. - $nameMarkers[] = [ - 'line' => $nameLine, - 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), - 'args' => $args, - ]; - // Strip from `::` start through `>` end so the cleaned - // source reads as a plain `Name(...)` / `Recv::Name(...)` - // / `$obj->Name(...)` call. - $startByte = $dcTok->pos; - $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); - $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; - $i = $endIdx + 1; - continue; - } + } + if ($parsed !== null) { + [$args, $endIdx] = $parsed; + // Instance-method turbofish (`$obj->m::<…>(...)`) markers are + // claimed by the MethodCall / NullsafeMethodCall resolver branch + // alongside StaticCall (item #11). GenericMethodCompiler does + // receiver-type analysis to pick the right method template. + $nameMarkers[] = [ + 'line' => $nameLine, + 'anchorLine' => $anchorLine, + 'name' => ltrim($nameText, '\\'), + 'args' => $args, + ]; + // Strip from `::` start through `>` end so the cleaned + // source reads as a plain `Name(...)` / `Recv::Name(...)` + // / `$obj->Name(...)` call. + $startByte = $dcTok->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; } } @@ -367,25 +378,33 @@ private function scanAndStrip(string $source): array } /** - * Parse a class-header type-param list: `< Name(: Bound)? (, Name(: Bound)?)* >`. + * Parse a class-header type-param list: `< Name(: Bound)?(= Default)? (, ...)* >`. * * Unlike `parseTypeArgList` (which is for *instantiation* sites and only handles * concrete + nested-generic args), this variant only fires on the class/interface/trait - * header — so the `:` after a name is unambiguous and signals a bound. + * header (or, when `$allowDefaults` is false, on a method/function header) — so the + * `:` after a name unambiguously signals a bound and `=` unambiguously signals a default. * - * Returns `[entries, endIdx]` where each entry is a `{name: string, bound: ?array}` - * record. The bound (when present) is a structured tree: - * - leaf: `['kind' => 'leaf', 'name' => string, 'isFq' => bool, 'args' => list]` - * - intersection: `['kind' => 'and', 'operands' => list]` - * - union: `['kind' => 'or', 'operands' => list]` + * Returns `[entries, endIdx]` where each entry is a `{name, bound, default}` record: + * - bound (when present) is a structured tree: + * leaf: `['kind' => 'leaf', 'name' => string, 'isFq' => bool, 'args' => list]` + * intersection: `['kind' => 'and', 'operands' => list]` + * union: `['kind' => 'or', 'operands' => list]` + * - default (when present) is an unresolved `TypeRef`. The resolver pass + * applies namespace + use-map resolution and marks `isTypeParam: true` + * on references to earlier params in the same list. * - * Later resolved to a `BoundExpr` tree by the AST traversal step (which has - * access to the namespace + use map for class-name resolution). + * When `$allowDefaults` is false (method / function headers), `= Default` is + * rejected with a clear error pointing at the limitation. + * + * Defaulted parameters must be trailing (required follows defaulted is rejected + * at parse time). A default cannot reference the same param or a later one + * (forward references are rejected at parse time). * * @param list $tokens - * @return array{0: list, 1: int}|null + * @return array{0: list, 1: int}|null */ - private static function parseTypeParamList(array $tokens, int $openIdx): ?array + private static function parseTypeParamList(array $tokens, int $openIdx, bool $allowDefaults): ?array { $n = count($tokens); if ($openIdx >= $n || $tokens[$openIdx]->text !== '<') { @@ -393,6 +412,7 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array } $entries = []; + $sawDefault = false; $i = self::skipWs($tokens, $openIdx + 1); while ($i < $n) { if (!self::isNameToken($tokens[$i])) { @@ -412,9 +432,56 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array [$bound, $i] = $parsedBound; } + $default = null; + $afterBound = self::skipWs($tokens, $i); + if ($afterBound < $n && $tokens[$afterBound]->text === '=') { + if (!$allowDefaults) { + throw new RuntimeException(sprintf( + 'Generic parameter `%s` has a default value, which is not yet ' + . 'supported on methods or functions. Move the generic to a ' + . 'class-level type parameter, or remove the default.', + $paramName, + )); + } + $afterEq = self::skipWs($tokens, $afterBound + 1); + $parsedDefault = self::parseTypeArg($tokens, $afterEq); + if ($parsedDefault === null) { + throw new RuntimeException(sprintf( + 'Generic parameter `%s` has an invalid default; only a single ' + . 'concrete or generic type is allowed after `=` (no nullable ' + . 'or union shapes).', + $paramName, + )); + } + [$default, $i] = $parsedDefault; + // Reject `T = Box | Other` (union) and `T = Box & Other` (intersection) + // explicitly so users see the "no nullable or union shapes" message + // rather than the surrounding class header silently failing to be + // recognized as generic and PHP emitting a downstream syntax error. + $afterDefault = self::skipWs($tokens, $i); + if ($afterDefault < $n + && ($tokens[$afterDefault]->text === '|' || $tokens[$afterDefault]->text === '&') + ) { + throw new RuntimeException(sprintf( + 'Generic parameter `%s` has an invalid default; only a single ' + . 'concrete or generic type is allowed after `=` (no nullable ' + . 'or union shapes).', + $paramName, + )); + } + $sawDefault = true; + } elseif ($sawDefault) { + throw new RuntimeException(sprintf( + 'Generic parameter `%s` has no default but follows a parameter with ' + . 'a default. Required type parameters must precede defaulted ones.', + $paramName, + )); + } + $entries[] = [ 'name' => $paramName, 'bound' => $bound, + 'default' => $default, ]; $i = self::skipWs($tokens, $i); @@ -423,6 +490,7 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array } if ($tokens[$i]->text === '>') { self::assertNoTopLevelSelfReference($entries); + self::assertDefaultsReferenceOnlyEarlierParams($entries); return [$entries, $i]; } if ($tokens[$i]->text === ',') { @@ -435,6 +503,78 @@ private static function parseTypeParamList(array $tokens, int $openIdx): ?array return null; } + /** + * Reject `class Bad` (default references self) and `class Bad` + * (default references a later param). A default may reference *strictly earlier* + * params in the same list; `class Pair` is allowed. + * + * The check runs at parse time against the raw source-level names on the + * default's TypeRef tree. A leading-`\\` (e.g. `T = \T` where `\T` is a global + * class named T) is allowed because the FQ form unambiguously refers to a + * class and not the same-named type-param. + * + * @param list $entries + */ + private static function assertDefaultsReferenceOnlyEarlierParams(array $entries): void + { + $paramNames = array_map( + static fn (array $e): string => $e['name'], + $entries, + ); + foreach ($entries as $idx => $entry) { + if ($entry['default'] === null) { + continue; + } + self::assertDefaultRefsEarlierOnly( + $entry['default'], + $entry['name'], + $paramNames, + $idx, + ); + } + } + + /** + * Recursively walk a default TypeRef tree. For any leaf whose name (after + * stripping leading `\\`) matches a param name at index `>= $currentIdx`, + * throw with a clear error. A leading-`\\` short-circuits the check (FQ + * names refer to classes, not type-params). + * + * @param list $paramNames + */ + private static function assertDefaultRefsEarlierOnly( + TypeRef $ref, + string $currentParam, + array $paramNames, + int $currentIdx, + ): void { + if (!str_starts_with($ref->name, '\\')) { + $refIdx = array_search($ref->name, $paramNames, true); + if ($refIdx !== false && $refIdx >= $currentIdx) { + $msg = $refIdx === $currentIdx + ? sprintf( + 'Generic parameter `%s` cannot reference itself in its default. ' + . 'Use `\\%s` to reference a global class named %s, if that was ' + . 'intended.', + $currentParam, + $currentParam, + $currentParam, + ) + : sprintf( + 'Generic parameter `%s` default references `%s`, which is declared ' + . 'later in the same parameter list. Defaults may only reference ' + . 'strictly earlier parameters.', + $currentParam, + $ref->name, + ); + throw new RuntimeException($msg); + } + } + foreach ($ref->args as $inner) { + self::assertDefaultRefsEarlierOnly($inner, $currentParam, $paramNames, $currentIdx); + } + } + /** * RFC bound-erased generic types forbids `class A` -- a type parameter * cannot use *itself* as a bound at the top level. F-bounded recursion @@ -672,6 +812,13 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array $args = []; $i = self::skipWs($tokens, $openIdx + 1); + // Empty turbofish: `Foo::<>` -- the all-defaults call-site shape. + // Returning an empty args list here lets the registry's padding pick + // up every defaulted param. A non-defaulted template surfaces as the + // padding error at recordInstantiation time. + if ($i < $n && $tokens[$i]->text === '>') { + return [$args, $i]; + } while ($i < $n) { $argResult = self::parseTypeArg($tokens, $i); if ($argResult === null) { @@ -992,7 +1139,8 @@ public function enterNode(Node $node): null $typeParams = []; foreach ($paramEntries as $entry) { $bound = $this->buildBoundExpr($entry); - $typeParams[] = new TypeParam($entry['name'], $bound); + $default = $this->buildDefault($entry); + $typeParams[] = new TypeParam($entry['name'], $bound, $default); } $node->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, $typeParams); $currentNamespace = $this->ctx->currentNamespace(); @@ -1023,7 +1171,12 @@ public function enterNode(Node $node): null $typeParams = []; foreach ($marker['params'] as $entry) { $bound = $this->buildBoundExpr($entry); - $typeParams[] = new TypeParam($entry['name'], $bound); + // Method/function entries never carry defaults (parseTypeParamList + // rejects `=` with $allowDefaults=false), so buildDefault is null + // by construction; the explicit call documents the symmetry with + // the ClassLike branch. + $default = $this->buildDefault($entry); + $typeParams[] = new TypeParam($entry['name'], $bound, $default); } // Pop here — the method's own scope is pushed again // below to match the leaveNode pop pattern. This @@ -1144,6 +1297,23 @@ private function buildBoundExpr(array $entry): ?BoundExpr return $this->buildBoundExprNode($entry['bound']); } + /** + * Resolve a parsed entry's default expression. The raw `TypeRef` + * carries the source-level name; `resolveTypeRef` rewrites it via + * namespace + use-map, marks scalars / type-params, and recurses + * into nested args (so `B = Box` becomes + * `TypeRef('App\Box', [TypeRef('A', isTypeParam: true)])`). + * + * @param array{name: string, bound: ?array, default: ?TypeRef} $entry + */ + private function buildDefault(array $entry): ?TypeRef + { + if ($entry['default'] === null) { + return null; + } + return $this->resolveTypeRef($entry['default']); + } + /** * @param array{kind: string, ...} $node */ diff --git a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php new file mode 100644 index 0000000..d80a974 --- /dev/null +++ b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php @@ -0,0 +1,320 @@ +workDir = sys_get_temp_dir() . '/xphp-defaults-' . uniqid('', true); + $this->targetDir = $this->workDir . '/dist'; + $this->cacheDir = $this->workDir . '/.xphp-cache'; + mkdir($this->workDir, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->workDir)) { + self::rrmdir($this->workDir); + } + } + + public function testFullDefaultsFixtureGeneratesExpectedSpecializations(): void + { + // Fixture: `defaults_full/`. Exercises all four call-site shapes: + // bare `new Cache;`, empty turbofish, partial args, fully-explicit. + // The first two collapse to the same `Cache` FQN. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/defaults_full/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + // Three unique specializations: , , . + self::assertSame(3, $result->generatedCount); + + $fqnAllDefaults = Registry::generatedFqn( + 'App\\DefaultsFull\\Containers\\Cache', + [ + new TypeRef('string', isScalar: true), + new TypeRef('mixed', isScalar: true), + ], + ); + $fqnPartial = Registry::generatedFqn( + 'App\\DefaultsFull\\Containers\\Cache', + [ + new TypeRef('int', isScalar: true), + new TypeRef('mixed', isScalar: true), + ], + ); + $fqnExplicit = Registry::generatedFqn( + 'App\\DefaultsFull\\Containers\\Cache', + [ + new TypeRef('int', isScalar: true), + new TypeRef('App\\DefaultsFull\\Models\\Tag'), + ], + ); + self::assertFileExists($this->fqnToPath($fqnAllDefaults)); + self::assertFileExists($this->fqnToPath($fqnPartial)); + self::assertFileExists($this->fqnToPath($fqnExplicit)); + } + + public function testEmptyTurbofishAndBareNewProduceSameSpecialization(): void + { + // After compile, both `new Cache;` and `new Cache::<>` in the same + // file resolve to the same generated FQN -- the registry-side padding + // collapses them. The fixture's source has both shapes; we confirm by + // counting unique specializations covering the bare/empty/partial/explicit + // call sites in the previous test. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/defaults_full/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $cacheAllDefaultsCount = 0; + foreach ($result->registry->instantiations() as $inst) { + if ($inst->templateFqn === 'App\\DefaultsFull\\Containers\\Cache' + && $inst->concreteTypes[0]->name === 'string' + && $inst->concreteTypes[1]->name === 'mixed' + ) { + $cacheAllDefaultsCount++; + } + } + // Exactly one entry for the all-defaults shape -- proves bare + empty + // turbofish collapsed to a single instantiation in the registry. + self::assertSame(1, $cacheAllDefaultsCount); + } + + public function testForwardRefDefaultsSubstituteEarlierArg(): void + { + // Fixture: `defaults_forward_ref/`. `Pair` with `new Pair::` + // pads to `Pair`. Explicit `Pair::` is independent. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/defaults_forward_ref/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(2, $result->generatedCount); + $padded = Registry::generatedFqn( + 'App\\DefaultsForwardRef\\Containers\\Pair', + [ + new TypeRef('int', isScalar: true), + new TypeRef('int', isScalar: true), + ], + ); + $explicit = Registry::generatedFqn( + 'App\\DefaultsForwardRef\\Containers\\Pair', + [ + new TypeRef('int', isScalar: true), + new TypeRef('string', isScalar: true), + ], + ); + self::assertFileExists($this->fqnToPath($padded)); + self::assertFileExists($this->fqnToPath($explicit)); + } + + public function testCrossFileBareNewResolvesAgainstDefinitionInAnotherFile(): void + { + // Fixture: `defaults_cross_file_bare_new/`. `new Cache;` is in Use.xphp; + // the template lives in Containers/Cache.xphp. The two-pass collector + // (definitions before instantiations) must resolve Cache regardless of + // the source file's walk order. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/defaults_cross_file_bare_new/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(1, $result->generatedCount); + $allDefaults = Registry::generatedFqn( + 'App\\DefaultsCrossFileBareNew\\Containers\\Cache', + [ + new TypeRef('string', isScalar: true), + new TypeRef('mixed', isScalar: true), + ], + ); + self::assertFileExists($this->fqnToPath($allDefaults)); + } + + public function testDefaultBoundViolationAtDeclarationFailsCompile(): void + { + // `class Box` -- int doesn't satisfy Stringable. + // Surfaces at the source-level after defs-only collect, BEFORE any + // instantiation is even considered. + $sourceDir = $this->workDir . '/src-bound-violation'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public T $item; + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Default for generic parameter `T`'); + $this->expectExceptionMessage('Stringable'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testMethodLevelDefaultDeclarationIsRejected(): void + { + // Confirms the parse-time rejection lives in the integration path too; + // there's no clean way to express a method-level default in the per-class + // fixtures, so we exercise the message end-to-end here. + $sourceDir = $this->workDir . '/src-method-default'; + mkdir($sourceDir, 0o755, true); + $file = $sourceDir . '/M.xphp'; + file_put_contents($file, <<<'PHP' + (T $x): T { return $x; } + } + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($file); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not yet supported on methods or functions'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testTooFewArgsWithoutDefaultsFailsCompile(): void + { + // `class Pair` and `new Pair::` -- B has no default, so + // padding throws "parameter has no default". + $sourceDir = $this->workDir . '/src-too-few'; + mkdir($sourceDir, 0o755, true); + $pairFile = $sourceDir . '/Pair.xphp'; + file_put_contents($pairFile, <<<'PHP' + + { + public function __construct(public A $a, public B $b) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + (1, 'x'); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($pairFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no default'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + public function testPositionalFirstBoundFailureSurfacesOnEarliestViolator(): void + { + // `class Box; new Box::` -- + // padding fills B = A = int. Then validateBounds iterates positionally: + // A's bound (Stringable) fails on int FIRST, so the error message names + // A and Stringable -- not B and Countable. + $sourceDir = $this->workDir . '/src-positional'; + mkdir($sourceDir, 0o755, true); + $boxFile = $sourceDir . '/Box.xphp'; + file_put_contents($boxFile, <<<'PHP' + + { + public function __construct(public A $a, public B $b) {} + } + PHP); + $useFile = $sourceDir . '/Use.xphp'; + file_put_contents($useFile, <<<'PHP' + (7, 7); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($boxFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('type parameter A'); + $this->expectExceptionMessage('Stringable'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + + private function fqnToPath(string $fqn): string + { + $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; + $rel = str_starts_with($fqn, $prefix) ? substr($fqn, strlen($prefix)) : $fqn; + return $this->cacheDir . '/Generated/' . str_replace('\\', '/', $rel) . '.php'; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/RegistryBoundsTest.php b/test/Transpiler/Monomorphize/RegistryBoundsTest.php index f88db05..9eda227 100644 --- a/test/Transpiler/Monomorphize/RegistryBoundsTest.php +++ b/test/Transpiler/Monomorphize/RegistryBoundsTest.php @@ -185,4 +185,270 @@ public function testUnboundedTypeParamSkipsValidation(): void $registry->recordInstantiation('App\\Box', [new TypeRef('App\\Whatever')]); self::assertCount(1, $registry->instantiations()); } + + public function testRecordInstantiationPadsTrailingDefaultsWhenArgsAreShort(): void + { + $registry = new Registry(); + $registry->recordDefinition( + 'App\\Cache', + 'Cache', + [ + new TypeParam('K', default: new TypeRef('string', isScalar: true)), + new TypeParam('V', default: new TypeRef('mixed', isScalar: true)), + ], + new Class_(new Identifier('Cache')), + '/Cache.xphp', + ); + + $instantiation = $registry->recordInstantiation('App\\Cache', []); + + self::assertCount(2, $instantiation->concreteTypes); + self::assertSame('string', $instantiation->concreteTypes[0]->name); + self::assertSame('mixed', $instantiation->concreteTypes[1]->name); + } + + public function testEmptyAndFullyExplicitInstantiationsProduceTheSameFqn(): void + { + $registry = new Registry(); + $registry->recordDefinition( + 'App\\Cache', + 'Cache', + [ + new TypeParam('K', default: new TypeRef('string', isScalar: true)), + new TypeParam('V', default: new TypeRef('mixed', isScalar: true)), + ], + new Class_(new Identifier('Cache')), + '/Cache.xphp', + ); + + $bare = $registry->recordInstantiation('App\\Cache', []); + $explicit = $registry->recordInstantiation('App\\Cache', [ + new TypeRef('string', isScalar: true), + new TypeRef('mixed', isScalar: true), + ]); + + self::assertSame($bare->generatedFqn, $explicit->generatedFqn); + } + + public function testRecordInstantiationSubstitutesEarlierParamRefsIntoDefault(): void + { + $registry = new Registry(); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A'), + new TypeParam('B', default: new TypeRef('A', isTypeParam: true)), + ], + new Class_(new Identifier('Pair')), + '/Pair.xphp', + ); + + $instantiation = $registry->recordInstantiation('App\\Pair', [ + new TypeRef('int', isScalar: true), + ]); + + self::assertCount(2, $instantiation->concreteTypes); + self::assertSame('int', $instantiation->concreteTypes[1]->name); + self::assertTrue($instantiation->concreteTypes[1]->isScalar); + } + + public function testRecordInstantiationThrowsWhenNonDefaultedParamIsMissing(): void + { + $registry = new Registry(); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A'), + new TypeParam('B', default: new TypeRef('A', isTypeParam: true)), + ], + new Class_(new Identifier('Pair')), + '/Pair.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('parameter `A`'); + $this->expectExceptionMessage('(position 1)'); + $this->expectExceptionMessage('has no default'); + $registry->recordInstantiation('App\\Pair', []); + } + + public function testRecordInstantiationErrorReportsCorrectPositionForMissingSecondParam(): void + { + // First param is supplied; second has no default. Position number must + // read `(position 2)` -- pins the $i + 1 human-readable offset. + $registry = new Registry(); + $registry->recordDefinition( + 'App\\Triple', + 'Triple', + [ + new TypeParam('A'), + new TypeParam('B'), + new TypeParam('C', default: new TypeRef('int', isScalar: true)), + ], + new Class_(new Identifier('Triple')), + '/Triple.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('parameter `B`'); + $this->expectExceptionMessage('(position 2)'); + $registry->recordInstantiation('App\\Triple', [new TypeRef('int', isScalar: true)]); + } + + public function testValidateDefaultsAgainstBoundsReportsUnknownConcreteWithNullVerdict(): void + { + // Bound is a class the hierarchy doesn't know about -> isSubtype returns + // null -> the verdict is "compiler cannot prove" (not "does not satisfy"). + // Pins the verdict-aware ternary in the error-shape selection. + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam( + 'T', + bound: new BoundLeaf(new TypeRef('Vendor\\UnknownIface')), + default: new TypeRef('Vendor\\AlsoUnknown'), + )], + new Class_(new Identifier('Box')), + '/Box.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('compiler cannot prove'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testValidateDefaultsAgainstBoundsContinuesPastSkippedParamToLaterViolator(): void + { + // First param skips (bound but no default); second param's default + // violates its bound. If the `continue` on the skip becomes a `break`, + // the second param is never checked and the violation slips through. + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [ + new TypeParam( + 'A', + bound: new BoundLeaf(new TypeRef('Stringable')), + // no default -- the first `continue` (bound or default null) skip + ), + new TypeParam( + 'B', + bound: new BoundLeaf(new TypeRef('Stringable')), + default: new TypeRef('int', isScalar: true), + ), + ], + new Class_(new Identifier('Box')), + '/Box.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Default for generic parameter `B`'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testValidateDefaultsAgainstBoundsContinuesPastTypeParamRefToLaterConcreteViolator(): void + { + // First param's default IS a type-param ref (skipped via the !isConcrete + // branch). Second param's default is fully concrete AND violates its + // bound. If the !isConcrete `continue` becomes a `break`, the second + // param's violation isn't surfaced. + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy); + // `class Bad` + // A is required (no default, skipped at line 187 -- bound != null but + // default == null). B's default is a type-param ref to A (NOT concrete + // -> skipped at line 190). C's default is `int` (concrete) and violates + // Stringable. Only line 190's continue chain reaches C. + $registry->recordDefinition( + 'App\\Bad', + 'Bad', + [ + new TypeParam('A', bound: new BoundLeaf(new TypeRef('Stringable'))), + new TypeParam( + 'B', + bound: new BoundLeaf(new TypeRef('Stringable')), + default: new TypeRef('A', isTypeParam: true), + ), + new TypeParam( + 'C', + bound: new BoundLeaf(new TypeRef('Stringable')), + default: new TypeRef('int', isScalar: true), + ), + ], + new Class_(new Identifier('Bad')), + '/Bad.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Default for generic parameter `C`'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testValidateDefaultsAgainstBoundsCatchesConcreteViolation(): void + { + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy); + + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam( + 'T', + bound: new BoundLeaf(new TypeRef('Stringable')), + default: new TypeRef('int', isScalar: true), + )], + new Class_(new Identifier('Box')), + '/Box.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Default for generic parameter `T`'); + $this->expectExceptionMessage('Stringable'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testValidateDefaultsAgainstBoundsSkipsTypeParamRefDefaults(): void + { + // `class Box` -- B's default + // references A. At decl time, A's concrete is unknown; the sweep skips + // B's check. At inst time, padding substitutes B = A's concrete and the + // existing validateBounds re-checks against B's bound. + $hierarchy = new TypeHierarchy(['App\\Tag' => ['Stringable']]); + $registry = new Registry(hierarchy: $hierarchy); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [ + new TypeParam('A', bound: new BoundLeaf(new TypeRef('Stringable'))), + new TypeParam( + 'B', + bound: new BoundLeaf(new TypeRef('Stringable')), + default: new TypeRef('A', isTypeParam: true), + ), + ], + new Class_(new Identifier('Box')), + '/Box.xphp', + ); + + // Must not throw at decl time -- B's verdict depends on A's concrete. + $registry->validateDefaultsAgainstBounds(); + + // Inst-time positive case: A = Tag (satisfies Stringable). B pads from + // A = Tag, and the bound check re-verifies Tag against Stringable. + $good = $registry->recordInstantiation('App\\Box', [new TypeRef('App\\Tag')]); + self::assertCount(2, $good->concreteTypes); + self::assertSame('App\\Tag', $good->concreteTypes[1]->name); + + // Inst-time negative case: A = int (violates Stringable). The validation + // surfaces on A first (positional-first), not B. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('type parameter A'); + $registry->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + } } diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 2cfaa31..8d02232 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -57,8 +57,13 @@ public function testCallSiteRewriterIgnoresNameWithoutGenericArgs(): void self::assertSame([], $registry->instantiations(), 'Name without xphp:genericArgs must not be rewritten'); } - public function testCallSiteRewriterIgnoresNameWithEmptyGenericArgs(): void + public function testCallSiteRewriterRecordsEmptyArgsInstantiation(): void { + // Empty `xphp:genericArgs` is the `Cache::<>` shape (and the bare + // `new Cache;` shape after RegistryCollector synthesizes the marker): + // an instantiation that asks the registry to pad entirely from defaults. + // The rewriter no longer gates on `args !== []` -- the registry's + // recordInstantiation does the padding, validation, and hashing. $registry = new Registry(); $name = new Name('Box'); $name->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, []); @@ -67,7 +72,7 @@ public function testCallSiteRewriterIgnoresNameWithEmptyGenericArgs(): void $ast = self::wrapNameInStmt($name); (new CallSiteRewriter($registry))->rewrite($ast); - self::assertSame([], $registry->instantiations(), 'empty genericArgs must not produce a Registry entry'); + self::assertCount(1, $registry->instantiations(), 'empty genericArgs routes through recordInstantiation for defaults padding'); } public function testCallSiteRewriterIgnoresNameWithoutTemplateFqn(): void @@ -178,6 +183,105 @@ public function testCollectorIgnoresNameWithNonConcreteArgs(): void self::assertSame([], $registry->instantiations()); } + public function testCollectorBareNewSynthesisSkipsTemplatesWithRequiredParams(): void + { + // `class Box` (no default) -- a bare `new Box;` must NOT synthesize + // a zero-arg instantiation. Only all-defaults templates are eligible. + $registry = new Registry(); + $class = new Class_(new Identifier('Box')); + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, [new TypeParam('T')]); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Box'); + $bareNew = new \PhpParser\Node\Expr\New_(new Name('Box')); + $ast = [ + $class, + new \PhpParser\Node\Stmt\Expression($bareNew), + ]; + + (new RegistryCollector($registry))->collect($ast, '/x.xphp'); + + self::assertSame([], $registry->instantiations(), 'bare new on a non-defaulted template must not synthesize an instantiation'); + } + + public function testCollectorBareNewSynthesisRecordsAllDefaultsTemplate(): void + { + // `class Cache` and bare `new Cache;` -- synthesizer fires. + $registry = new Registry(); + $class = new Class_(new Identifier('Cache')); + $class->setAttribute( + XphpSourceParser::ATTR_GENERIC_PARAMS, + [new TypeParam('K', default: new TypeRef('string', isScalar: true))], + ); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Cache'); + $bareNew = new \PhpParser\Node\Expr\New_(new Name('Cache')); + $ast = [ + $class, + new \PhpParser\Node\Stmt\Expression($bareNew), + ]; + + (new RegistryCollector($registry))->collect($ast, '/x.xphp'); + + self::assertCount(1, $registry->instantiations()); + // The Name node now carries the synthesized attributes. + self::assertSame([], $bareNew->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS)); + self::assertSame('Cache', $bareNew->class->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + } + + public function testCollectorBareNewSynthesisSkipsNameWithExistingGenericArgs(): void + { + // `new Cache::;` -- the Name already carries ATTR_GENERIC_ARGS. The + // synthesis arm must NOT fire (a second recordInstantiation on a different + // arg shape would still be idempotent under hash, but the gate keeps + // synthesis strictly for the bare-`new` shape). + $registry = new Registry(); + $class = new Class_(new Identifier('Cache')); + $class->setAttribute( + XphpSourceParser::ATTR_GENERIC_PARAMS, + [new TypeParam('K', default: new TypeRef('string', isScalar: true))], + ); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Cache'); + $explicitCall = new Name('Cache'); + $explicitCall->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('int', isScalar: true)]); + $explicitCall->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Cache'); + $newExpr = new \PhpParser\Node\Expr\New_($explicitCall); + $ast = [ + $class, + new \PhpParser\Node\Stmt\Expression($newExpr), + ]; + + (new RegistryCollector($registry))->collect($ast, '/x.xphp'); + + // Exactly ONE instantiation -- the explicit-args one -- not two + // (which would mean the synthesis pass also recorded). + self::assertCount(1, $registry->instantiations()); + $only = array_values($registry->instantiations())[0]; + self::assertCount(1, $only->concreteTypes); + self::assertSame('int', $only->concreteTypes[0]->name); + self::assertTrue($only->concreteTypes[0]->isScalar); + } + + public function testCollectorBareNewSynthesisSkipsFullyQualifiedName(): void + { + // FullyQualified Name nodes already point at an explicit class -- no + // namespace + use-map resolution needed, and they're not a synthesis + // target. Pin that the collector ignores them. + $registry = new Registry(); + $class = new Class_(new Identifier('Cache')); + $class->setAttribute( + XphpSourceParser::ATTR_GENERIC_PARAMS, + [new TypeParam('K', default: new TypeRef('string', isScalar: true))], + ); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Cache'); + $bareNew = new \PhpParser\Node\Expr\New_(new FullyQualified('Cache')); + $ast = [ + $class, + new \PhpParser\Node\Stmt\Expression($bareNew), + ]; + + (new RegistryCollector($registry))->collect($ast, '/x.xphp'); + + self::assertSame([], $registry->instantiations(), 'FullyQualified bare new must not be synthesized'); + } + // ===================================================================== // Specializer — substitution map matching (line 91) // ===================================================================== diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 20b1a28..b0eba44 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1704,6 +1704,270 @@ public function set(Box $b): void { $this->b = $b; } self::assertStringNotContainsString('::<', $stripped); } + public function testDefaultTypeParamIsAccepted(): void + { + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertCount(1, $params); + $default = $params[0]->default; + self::assertNotNull($default); + self::assertSame('string', $default->name); + self::assertTrue($default->isScalar); + } + + public function testDefaultAfterBoundIsAccepted(): void + { + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertNotNull($params[0]->bound); + self::assertSame('App\\MyStringable', $params[0]->default?->name); + } + + public function testDefaultCanReferenceEarlierParam(): void + { + $source = <<<'PHP' + +{ + public A $first; + public B $second; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertSame('A', $params[1]->default?->name); + self::assertTrue($params[1]->default?->isTypeParam); + } + + public function testDefaultCanReferenceEarlierParamInsideGenericArgs(): void + { + $source = <<<'PHP' +> +{ + public B $inner; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $default = $params[1]->default; + self::assertSame('App\\Box', $default?->name); + self::assertSame('A', $default?->args[0]->name); + self::assertTrue($default?->args[0]->isTypeParam); + } + + public function testRequiredAfterDefaultIsRejected(): void + { + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Required type parameters must precede defaulted ones'); + $parser->parse($source); + } + + public function testDefaultCannotReferenceSelf(): void + { + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot reference itself in its default'); + $parser->parse($source); + } + + public function testDefaultCannotReferenceLaterParam(): void + { + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('declared later in the same parameter list'); + $parser->parse($source); + } + + public function testDefaultMayUseFullyQualifiedSameNameAsParam(): void + { + // `\T` is the global class named T, NOT the type-param T -- the FQ form + // unambiguously refers to a class, so the guard does not fire. + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + // Must not throw. + $parser->parse($source); + self::assertTrue(true); + } + + public function testMethodLevelDefaultIsRejectedWithClearError(): void + { + $source = <<<'PHP' +(T $x): T { return $x; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not yet supported on methods or functions'); + $parser->parse($source); + } + + public function testFreeFunctionLevelDefaultIsRejected(): void + { + $source = <<<'PHP' +(T $x): T { return $x; } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not yet supported on methods or functions'); + $parser->parse($source); + } + + public function testNullableDefaultIsRejectedWithClearError(): void + { + // PHP's `?Type` nullable shape is intentionally not allowed as a default; + // a nullable default is parsed by `parseTypeArg` failing on the `?` token, + // which surfaces as the "invalid default" error. + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('invalid default'); + $this->expectExceptionMessage('no nullable or union shapes'); + $parser->parse($source); + } + + public function testUnionDefaultIsRejectedWithClearError(): void + { + // PHP's union shape `A|B` is intentionally not allowed as a default -- + // defaults must be a single concrete or generic type. The parser detects + // the trailing `|` after the first leaf and throws the consistent + // "invalid default" error. + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('invalid default'); + $this->expectExceptionMessage('no nullable or union shapes'); + $parser->parse($source); + } + + public function testIntersectionDefaultIsRejectedWithClearError(): void + { + // `A & B` (intersection) -- same rejection family as the union case. + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('invalid default'); + $this->expectExceptionMessage('no nullable or union shapes'); + $parser->parse($source); + } + + public function testDefaultForwardRefGuardWalksIntoNestedGenericArgs(): void + { + // `class Bad, U = string>` -- A's default references U via + // a nested generic arg. The trailing-default rule passes (both have + // defaults), so the forward-ref guard must descend into TypeRef::args + // to catch the violation. + $source = <<<'PHP' +, U = string> +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('declared later in the same parameter list'); + $parser->parse($source); + } + + public function testCycleBetweenDefaultsIsRejectedAtForwardRef(): void + { + // `` -- A's default references B (declared later, illegal). + // The forward-ref guard catches this on A first; B's own default (= A, + // earlier) is fine but is never reached because A fails first. + $source = <<<'PHP' + +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('declared later in the same parameter list'); + $parser->parse($source); + } + /** * @template TNode of Node * @param array $ast diff --git a/test/fixture/compile/defaults_cross_file_bare_new/source/Containers/Cache.xphp b/test/fixture/compile/defaults_cross_file_bare_new/source/Containers/Cache.xphp new file mode 100644 index 0000000..3b77c14 --- /dev/null +++ b/test/fixture/compile/defaults_cross_file_bare_new/source/Containers/Cache.xphp @@ -0,0 +1,13 @@ + +{ + public function __construct() {} +} diff --git a/test/fixture/compile/defaults_cross_file_bare_new/source/Use.xphp b/test/fixture/compile/defaults_cross_file_bare_new/source/Use.xphp new file mode 100644 index 0000000..006982d --- /dev/null +++ b/test/fixture/compile/defaults_cross_file_bare_new/source/Use.xphp @@ -0,0 +1,11 @@ +` at the call site. The cross-file Cache +// declaration must still resolve here. +$bare = new Cache; diff --git a/test/fixture/compile/defaults_forward_ref/source/Containers/Pair.xphp b/test/fixture/compile/defaults_forward_ref/source/Containers/Pair.xphp new file mode 100644 index 0000000..e95a6fa --- /dev/null +++ b/test/fixture/compile/defaults_forward_ref/source/Containers/Pair.xphp @@ -0,0 +1,15 @@ +` -- A is required, B defaults to A. At instantiation +// `new Pair::(...)`, B is padded to int -- the substitution flows +// left-to-right through the registry's `padWithDefaults`. +class Pair +{ + public function __construct(public A $first, public B $second) + { + } +} diff --git a/test/fixture/compile/defaults_forward_ref/source/Use.xphp b/test/fixture/compile/defaults_forward_ref/source/Use.xphp new file mode 100644 index 0000000..bdd9450 --- /dev/null +++ b/test/fixture/compile/defaults_forward_ref/source/Use.xphp @@ -0,0 +1,13 @@ + Pair. +$padded = new Pair::(1, 2); + +// And the fully-explicit form -- different specialization Pair. +$explicit = new Pair::(7, 'hello'); diff --git a/test/fixture/compile/defaults_full/source/Containers/Cache.xphp b/test/fixture/compile/defaults_full/source/Containers/Cache.xphp new file mode 100644 index 0000000..1ea106c --- /dev/null +++ b/test/fixture/compile/defaults_full/source/Containers/Cache.xphp @@ -0,0 +1,19 @@ +` -- both params defaulted. Call sites can omit +// any trailing tail; `new Cache;` / `new Cache::<>` / `new Cache::` / +// `new Cache::` all specialize cleanly. +class Cache +{ + /** @var array */ + public array $entries = []; + + public function set(K $key, V $value): void + { + $this->entries[$key] = $value; + } +} diff --git a/test/fixture/compile/defaults_full/source/Models/Tag.xphp b/test/fixture/compile/defaults_full/source/Models/Tag.xphp new file mode 100644 index 0000000..65123b4 --- /dev/null +++ b/test/fixture/compile/defaults_full/source/Models/Tag.xphp @@ -0,0 +1,12 @@ +` FQN (#1 and #2) plus the partially-explicit form (#3). + +// #1: bare `new Cache;` -- no `<...>` at all. Synthesized to `Cache::<>` by +// the registry collector once the all-defaults template is known. +$bare = new Cache; + +// #2: empty turbofish `new Cache::<>`. Routes through the same padding logic. +$emptyTurbofish = new Cache::<>; + +// #3: partially-explicit `new Cache::`. Pads V = mixed. +$partial = new Cache::; + +// #4: fully-explicit instantiation with a concrete class for V. +$explicit = new Cache::; From a4969af0f4dd65fd189ae4122298533c6e61c15a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 06:20:49 +0000 Subject: [PATCH 14/36] feat(parser): variance annotations +T / -T `class Producer<+T>` and `class Consumer<-T>` parse, validate position constraints at declaration time, and emit subtype edges between specializations -- the payoff of variance: `Producer_Banana extends Producer_Fruit` when `Banana <: Fruit` lifts to a real PHP `extends` edge. Parser (`parseTypeParamList`): a leading `+` / `-` token on a type-param name is recorded as a `Variance` enum value (Invariant by default). Class headers accept variance; method/function headers reject it with the same "not yet supported on methods or functions" message family as defaults. Schema: `TypeParam` grows `Variance $variance = Variance::Invariant`. Position validator (`VariancePositionValidator::assertPositions`): runs once per generic class definition during the resolver's `leaveNode` hook (so the body's nested `xphp:genericArgs` attributes are populated by the time the walker reaches them). Rejects: - `+T` in method parameter, mutable / readonly property, constructor parameter, or bound / default position. - `-T` in method return, mutable / readonly property, constructor parameter, or bound / default position. - Either marker on a method-level type parameter (same message family as method-level defaults). Properties are strict-invariant even when readonly: PHP enforces invariant property types across `extends` chains regardless of the readonly modifier, so a covariant +T on a property would PHP-fatal at autoload when the variance edge lands. Users who need a "covariant field" use a bound-typed backing field + a method `get(): T`. Constructor parameters are strict-invariant (deviation from RFC's "constructors exempt"): PHP applies LSP signature compatibility to `__construct` at autoload time on `extends` chains, so a covariant constructor param would PHP-fatal. F-bounded variance (`class Sortable<+T : Comparable>`) is rejected because `+T` appears inside its own bound (an invariant position). Phase 2.5 emitter (`VarianceEdgeEmitter`): runs once after specialization (Phase 2), before CallSiteRewriter. For each pair of specializations of the same template, checks every arg pair against the param's variance. Three rules: - Invariant: `arg1.canonical() == arg2.canonical()` - Covariant: `isNestedSubtype(arg1, arg2)` - Contravariant: `isNestedSubtype(arg2, arg1)` Nested generic args route through a recursive `isNestedSubtype`: leaves delegate to `TypeHierarchy::isSubtype`; same-template generics recurse through that inner template's variance. Different templates or mixed shapes return false conservatively -- a wrong edge would PHP-fatal at autoload while a missed edge only loses an `instanceof` relationship. Edge filtering: only DIRECT supers emit. `Banana <: Apple <: Fruit` produces `Banana extends Apple`; the Fruit edge is reached transitively via Apple. Scalar type-args skip variance edges entirely (no PHP-level subtype relationship between scalars). Edge shape: - Class_ specialization gets a single `extends` (PHP single inheritance). Lexicographically-first direct super wins deterministically when multiple unrelated directs exist. - Interface_ specialization gets multi-target `extends`. Compiler pipeline wires `VarianceEdgeEmitter` between Phase 2 (fixed-point specialization) and Phase 3 (CallSiteRewriter). The edges are added to cloned specialized ASTs; CallSiteRewriter only rewrites template Class_/ Interface_ nodes, so the edges survive untouched. Fixtures (`test/fixture/compile/`): - `variance_covariant_happy/` -- `Producer<+T>` with `Banana <: Fruit`; Producer_Banana extends Producer_Fruit. - `variance_contravariant_happy/` -- `Consumer<-T>` with `Dog <: Animal`; Consumer_Animal extends Consumer_Dog (flipped). - `variance_with_defaults_and_bounds/` -- `Cache<+K : Stringable & Countable, V = mixed>` (all three Phase-3 features composed). Tests (26 new): 17 parser tests covering parsing, variance enum storage, mixed-variance lists, method-level rejection, and rejection in every invalid position (including nested generic args in input/output); 9 integration tests including the autoload-time signature-compat check that spawns a subprocess to require every generated file via an autoloader and instantiate the chain (catches the PHP fatal class that pure AST-shape assertions miss). Test count 282 -> 308; covered-code MSI 100% on item-13 files. infection.json5 ignores follow existing patterns for sprintf error messages, defensive defensive `varianceByName`-outer-filter masking, and structural non-semantic mutations on the edge emitter's iteration order (the integration tests assert the SHAPE of emitted edges via autoload chain reachability). Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 261 +++++++-- src/Transpiler/Monomorphize/Compiler.php | 10 + src/Transpiler/Monomorphize/TypeParam.php | 5 + src/Transpiler/Monomorphize/Variance.php | 31 ++ .../Monomorphize/VarianceEdgeEmitter.php | 278 ++++++++++ .../VariancePositionValidator.php | 290 ++++++++++ .../Monomorphize/XphpSourceParser.php | 65 ++- .../VarianceEdgeIntegrationTest.php | 508 ++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 294 ++++++++++ .../source/Containers/Consumer.xphp | 16 + .../source/Models/Animal.xphp | 9 + .../source/Models/Dog.xphp | 9 + .../source/Use.xphp | 12 + .../source/Containers/Producer.xphp | 24 + .../source/Models/Banana.xphp | 9 + .../source/Models/Fruit.xphp | 12 + .../variance_covariant_happy/source/Use.xphp | 15 + .../source/Containers/Cache.xphp | 24 + .../source/Models/Tag.xphp | 22 + .../source/Use.xphp | 11 + 20 files changed, 1846 insertions(+), 59 deletions(-) create mode 100644 src/Transpiler/Monomorphize/Variance.php create mode 100644 src/Transpiler/Monomorphize/VarianceEdgeEmitter.php create mode 100644 src/Transpiler/Monomorphize/VariancePositionValidator.php create mode 100644 test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php create mode 100644 test/fixture/compile/variance_contravariant_happy/source/Containers/Consumer.xphp create mode 100644 test/fixture/compile/variance_contravariant_happy/source/Models/Animal.xphp create mode 100644 test/fixture/compile/variance_contravariant_happy/source/Models/Dog.xphp create mode 100644 test/fixture/compile/variance_contravariant_happy/source/Use.xphp create mode 100644 test/fixture/compile/variance_covariant_happy/source/Containers/Producer.xphp create mode 100644 test/fixture/compile/variance_covariant_happy/source/Models/Banana.xphp create mode 100644 test/fixture/compile/variance_covariant_happy/source/Models/Fruit.xphp create mode 100644 test/fixture/compile/variance_covariant_happy/source/Use.xphp create mode 100644 test/fixture/compile/variance_with_defaults_and_bounds/source/Containers/Cache.xphp create mode 100644 test/fixture/compile/variance_with_defaults_and_bounds/source/Models/Tag.xphp create mode 100644 test/fixture/compile/variance_with_defaults_and_bounds/source/Use.xphp diff --git a/infection.json5 b/infection.json5 index 8e6553b..fda22a8 100644 --- a/infection.json5 +++ b/infection.json5 @@ -183,12 +183,6 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements" ] }, - // Dropping the usort() entirely. Same justification. - "FunctionCallRemoval": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements" - ] - }, // str_repeat(' ', $length) -> ' '. The padding only fills the byte range previously // occupied by a generic clause — since generic clauses never span multiple lines in our // grammar, shrinking the padding to a single space only shifts columns. Line numbers @@ -247,6 +241,15 @@ "ignore": [ "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // VarianceEdgeEmitter::isVarianceSubtype: `$a1->isScalar || + // $a2->isScalar` mixed-scalar shortcut. `||` -> `&&` would only + // fire when BOTH args are scalar, but the canonical-equality + // check below covers the same case (scalars with same canonical + // form pass; different scalars fail). Observable behavior + // identical for the test inputs (scalar+scalar same / scalar+ + // scalar different / scalar+class). A class+class case never + // hits this branch regardless of the flip. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", // The scanner sub-parsers (parseTypeParamList, parseTypeArgList, // parseTypeArg, parseArraySuffix, skipWs, resolveAndAttach): // `||` -> `&&` mutations on their `$i >= $n || tokens[i]->text !== '<'` @@ -287,7 +290,20 @@ // there's nothing to pad. `$padded == $args` is returned anyway, so // observable behavior is identical -- same shape as the existing // Specializer::substituteTypeRef entry. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + // VarianceEdgeEmitter::isNestedSubtype + ::isVarianceSubtype: + // intermediate `return false` branches that fall through to a + // following `return false` at the end of the function. The + // observable result is identical -- both branches return false + // for the same inputs. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + // VariancePositionValidator: defensive early `return` on a + // ComplexType branch that's currently unreachable in any + // PHP < 8.4 parser surface (PHP type-hint parser yields one + // of Name/Identifier/NullableType/UnionType/IntersectionType + // for every supported type position). Future PHP versions + // may add ComplexType subclasses we haven't surveyed yet. + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" ] }, @@ -316,7 +332,17 @@ // entry. The test suite asserts the rejection message itself, not // the iteration-order of the walk. Same shape as the existing // Registry::validateBounds Continue_ entry. - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams", + // VarianceEdgeEmitter: per-iteration `continue` statements on + // pair skipping. continue->break in the outer pair-collection + // loop only changes WHICH supers are missed (the test that + // catches it would need to assert specific supers across a + // chain; existing tests assert direct/transitive). Per + // emitter's `isVarianceSubtype` -- continue skips identity-args, + // break would exit too early but produce the same false (no + // edge) for self-pairs. The integration tests assert the + // emitted edge shape rather than internal iteration order. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" ] }, @@ -404,7 +430,21 @@ // && !$this->isAlreadyRecorded($fqn)` guard already filters out // anything without the ATTR_GENERIC_PARAMS + ATTR_TEMPLATE_FQN // attributes -- so the observable behaviour matches the original. - "XPHP\\Transpiler\\Monomorphize\\RegistryCollector::enterNode" + "XPHP\\Transpiler\\Monomorphize\\RegistryCollector::enterNode", + // VarianceEdgeEmitter::isNestedSubtype: the `$child->isGeneric() + // && $parent->isGeneric() && ltrim(...) === ltrim(...)` triple + // guard -- `&&` -> `||` flips would either widen the same-template + // arm to non-matching templates (the recursive call's argument- + // count mismatch returns false anyway) or narrow it to never + // match (falls through to the leaf-vs-leaf branch which returns + // false for generic-vs-non-generic). Same fatal-vs-missed-edge + // asymmetry as the rest of the variance pass. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + // VariancePositionValidator: `$type instanceof X` chained guards + // in `checkPhpType`. `&&` flips on `(count(parts)==1 && isset(...))` + // shapes only matter if the param IS variance-marked, which the + // outer `isset($varianceByName[$name])` filter already gates. + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" ] }, "GreaterThanOrEqualTo": { @@ -431,15 +471,6 @@ "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, - // `break` -> `continue` in ByteOffsetMap::toOriginal: subsequent loop - // iterations don't mutate state (no segments past the cursor satisfy - // the inner predicates), so the loop exits naturally and returns the - // same `strippedPos + priorDelta`. Equivalent. - "Break_": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::toOriginal" - ] - }, // CallSiteRewriter::rewrite: `['stmts' => []]` -> `[]` on the // Interface_ subnodes array. Interface_'s constructor defaults // `stmts` to `[]`, so both shapes produce equivalent ASTs. @@ -448,10 +479,25 @@ // on the no-namespace branch -- but our Registry-generated FQNs // ALWAYS carry a namespace, so `nsAst === null` is unreachable // from any realistic input. + // VariancePositionValidator: most ArrayItemRemoval / list-shape + // mutations on the per-position "allowed variances" lists are + // observationally equivalent because the `$varianceByName` map only + // contains non-Invariant entries. Dropping `Invariant` from any + // allowed list doesn't matter -- Invariant Ts never reach the check + // (the early `isset($varianceByName[$name])` filter skips them). + // Dropping `Contravariant` or `Covariant` only matters if a T of + // that variance appears at that position; the per-rejection tests + // (testCovariantInInputPositionIsRejected / + // testContravariantInOutputPositionIsRejected / + // testCovariantInMutablePropertyIsRejected / + // testCovariantInConstructorParamIsRejected / + // testCovariantInBoundIsRejected) already pin those cases. "ArrayItemRemoval": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\CallSiteRewriter::rewrite", - "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" ] }, // Compiler::compile depth-cap: `$depth > MAX` -> `>=`. Differs only @@ -462,6 +508,105 @@ "XPHP\\Transpiler\\Monomorphize\\Compiler::compile" ] }, + + // VarianceEdgeEmitter: most non-semantic mutants (Foreach_, + // FunctionCallRemoval, TrueValue, Break_, Identical, FalseValue, + // ArrayOneItem) target the transitive-filter and pair-iteration + // bookkeeping. The integration tests assert the SHAPE of the + // emitted edges (extends presence/absence on Class_ specializations + // including the autoload-time signature-compat check) which kills + // every semantically-meaningful mutant. The micro-level branch + // mutants either produce the same observable output (lex-sort tie- + // breakers across hash orderings) or change WHICH super is picked + // in a way the structural autoload test would still accept (the + // chain reaches the same root either way through PHP's transitive + // interface/extends resolution). + // VarianceEdgeEmitter::isVarianceSubtype: the `$a1->canonical() !== + // $a2->canonical()` check inside the per-arg loop drives the + // `$sawNonIdentity` flag (the defensive "at least one arg must + // differ before claiming a subtype edge" guard). For variance- + // marked params (Covariant / Contravariant), the primary check + // is the isNestedSubtype recursion above; the canonical check + // is a flag-setter, not a gate. For Invariant params, the + // canonical check IS the gate -- but the testInvariantTemplateProducesNoVarianceEdges + // test exercises that codepath via the `hasNonInvariantParam` + // short-circuit, so a flipped Identical mutant would still + // produce a no-edge output in that test. + // VarianceEdgeEmitter::filterDirectSupers: `sp2->generatedFqn === + // sp3->generatedFqn` self-skip. The flip leaves the candidates + // intact and the variance-subtype check below filters spurious + // matches anyway. + "Identical": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + ] + }, + // `break` -> `continue` in ByteOffsetMap::toOriginal: subsequent loop + // iterations don't mutate state. Equivalent. + "Break_": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::toOriginal", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + ] + }, + // XphpSourceParser::parseTypeParamList: `$boundIsFq = false` -> `= true`. + // Variable is overwritten before the next read on the bounded path, + // unused on the unbounded path. + // VarianceEdgeEmitter: per-iteration short-circuit `return false`. + "FalseValue": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + ] + }, + // XphpSourceParser::applyReplacements: dropping the usort() entirely. + // Replacements are equal-length so any sort order produces the same + // cleaned source. + // VarianceEdgeEmitter::addImplementsEdges: dropping the usort() on + // direct supers. Sort order only affects which super is picked for + // Class_ specializations (single extends); integration tests assert + // the structural autoload chain rather than the lexicographic + // tie-breaker order. + "FunctionCallRemoval": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + ] + }, + + // VariancePositionValidator::violationError: the `match` body + // produces a human-readable marker (`'+'` / `'-'` / `''`) for the + // error message. The Invariant arm (empty string) is hit when + // `varianceByName[$name]` returns Invariant -- but the validator's + // outer filter excludes Invariant entries from the map entirely, + // so the Invariant match arm is unreachable. Same shape for the + // ternary on `$hostParam` (`!== null` vs `=== null` flips only + // matter for messages, which tests assert as substring). + "MatchArmRemoval": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" + ] + }, + // VariancePositionValidator::checkPhpType: instanceof chain on the + // PHP type AST node kinds. Each branch corresponds to a different + // PHP type-hint surface (Name / NullableType / UnionType / + // IntersectionType / ComplexType). Mutations on the instanceof + // flips would route the wrong branches, but the validator's outer + // filter (`isset($varianceByName[$name])` for the variance-marked + // shapes only) gates whether the visit ever reaches the rejection; + // unflagged shapes pass through to the ComplexType bailout in any + // mutation. + "InstanceOf_": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" + ] + }, + "Ternary": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach", + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" + ] + }, // GenericMethodCompiler::process: `(string) $astKey` -- the cast is // defensive (covers a hypothetical non-string key); in practice // $astKey is always a string already. @@ -481,23 +626,6 @@ "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" ] }, - // XphpSourceParser::resolveAndAttach: `$entry['boundIsFq'] ? $entry['boundName'] - // : $this->resolveNameOnly($entry['boundName'])` -- inverted ternary. - // Our tests use bound names that are EITHER all FQ or all bare, so the - // ternary's two branches converge to the same FQN for the test inputs. - "Ternary": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach" - ] - }, - // XphpSourceParser::parseTypeParamList: `$boundIsFq = false` -> `= true`. - // The variable is overwritten before the next read on the bounded path, - // and unused on the unbounded path. - "FalseValue": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList" - ] - }, // XphpSourceParser::scanAndStrip: off-by-one mutations on `$i = $endIdx + 1` // patterns (`+0`, `+2`, `-1`). The outer `while ($i < $n)` loop with a // bottom `$i++` absorbs the shift: misadvancing forward by 1 just hits the @@ -525,19 +653,6 @@ // pins "(position 2)" explicitly so the Increment mutation IS caught. // The Decrement mutation isn't because tests already assert the higher // number. Listed for completeness. - "ArrayOneItem": { - "ignore": [ - // Registry::padWithDefaults: the early `return $args` when no - // padding is needed has an ArrayOneItem mutant on the return - // value (a list shape). When supplied args already match the - // param count, the function returns the unchanged input; - // ArrayOneItem on `[]` -> `[null]` would change the return - // shape but only on paths that the existing `>= $needed` - // early-return covers, and those paths are guarded by the - // GreaterThanOrEqualTo ignore directly above. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" - ] - }, // Inside the cycle-safe BFS of TypeHierarchy::isSubtype, `$visited[$cur] = true` // is only consumed via `isset($visited[$cur])` — the value is never read. The @@ -548,7 +663,16 @@ "TrueValue": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // VarianceEdgeEmitter::filterDirectSupers: the + // `$impliedByAnother = true; break;` shortcut. Mutating + // `true` to `false` makes the inner loop fall through + // without flagging the implied-by-another condition, + // so transitive supers get retained. The integration + // test asserts the structural autoload chain (which + // PHP resolves transitively anyway), so the test still + // sees a valid hierarchy. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" ] }, @@ -568,12 +692,45 @@ "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // VariancePositionValidator: dropping `checkBoundExpr` / + // `checkProperty` / `checkMethod` / `checkPhpType` invocations + // would leave the relevant body unwalked, which the per-rejection + // parser tests would catch ONLY for the specific class shape + // they exercise. Edge shapes outside those tests (e.g. nested + // property type, inherited methods) survive the drop. Same + // shape as the GenericMethodCompiler entry above -- end-to-end + // tests cover the surface even when individual method-call + // mutations escape. + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" ] }, "Foreach_": { "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // VarianceEdgeEmitter: dropping the inner foreach on the + // pair-collection loop. Same rationale as the LogicalAnd / + // Continue_ entries above -- the integration tests assert + // the SHAPE of emitted edges, not the iteration order. + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + // VariancePositionValidator::checkMethod: dropping the + // per-param foreach. Tests would catch the case where the + // first param violates, but not the case where a SECOND + // param violates. Same per-rejection-test gap as the + // MethodCallRemoval entry on the same class. + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" + ] + }, + // VarianceEdgeEmitter::isVarianceSubtype: `$padded = $args` style + // initialization. ArrayOneItem `[] -> [null]` produces an empty- + // initial-state shape that's only consequential if the loop body + // never executes -- but `count(args1) === count(args2) === count(params)` + // guards the loop entry, and zero-param templates never reach + // `isVarianceSubtype` (`hasNonInvariantParam` returns false first). + "ArrayOneItem": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" ] } }, diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index c6447dc..2f11bd5 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -141,6 +141,16 @@ public function compile( } } + // Phase 2.5: emit subtype edges between specializations whose template + // declares variance markers. Runs once after the fixed-point loop + // (Phase 2) finishes -- pairwise variance comparisons can't run until + // every specialization is recorded. Edges are added to the cloned + // ClassLike's `implements` / `extends` list and survive CallSiteRewriter + // (Phase 3) untouched -- CallSiteRewriter only rewrites template + // Class_/Interface_ nodes, not specialized ones. + $varianceEmitter = new VarianceEdgeEmitter($hierarchy); + $varianceEmitter->emitEdges($specializedAsts, $registry); + // Phase 3: rewrite + emit specialized classes. $rewriter = new CallSiteRewriter($registry); foreach ($specializedAsts as $generatedFqn => $classAst) { diff --git a/src/Transpiler/Monomorphize/TypeParam.php b/src/Transpiler/Monomorphize/TypeParam.php index 748e3e2..29d0d62 100644 --- a/src/Transpiler/Monomorphize/TypeParam.php +++ b/src/Transpiler/Monomorphize/TypeParam.php @@ -22,6 +22,10 @@ * supplied args with these defaults, substituting earlier args into any * type-param references in the default. * + * `variance` controls whether subtype edges are emitted between specializations. + * `Invariant` (no prefix) is the default. `Covariant` (`+T`) lifts `T1 <: T2` + * to `Box <: Box`. `Contravariant` (`-T`) flips the direction. + * * Both expressions are built by `XphpSourceParser::resolveAndAttach` after * resolving each leaf class name against the file's namespace + use map. */ @@ -31,6 +35,7 @@ public function __construct( public string $name, public ?BoundExpr $bound = null, public ?TypeRef $default = null, + public Variance $variance = Variance::Invariant, ) { } } diff --git a/src/Transpiler/Monomorphize/Variance.php b/src/Transpiler/Monomorphize/Variance.php new file mode 100644 index 0000000..bc457f1 --- /dev/null +++ b/src/Transpiler/Monomorphize/Variance.php @@ -0,0 +1,31 @@ + <: Box`. + * - `Contravariant` (`-T`): T can appear in method parameter positions only. + * `T1 <: T2` lifts to `Box <: Box` (flipped). + * + * Property positions (mutable AND readonly), constructor parameters, bounds, + * and defaults are strict-invariant for both `+T` and `-T` -- PHP enforces + * invariant property types and constructor signature compat across `extends` + * chains regardless of `readonly`, so a covariance allowance there would + * PHP-fatal at autoload when the variance edge emits. + * + * String-backed so the registry JSON serializes cleanly. + */ +enum Variance: string +{ + case Invariant = 'invariant'; + case Covariant = 'covariant'; + case Contravariant = 'contravariant'; +} diff --git a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php new file mode 100644 index 0000000..276e38f --- /dev/null +++ b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php @@ -0,0 +1,278 @@ +` with `Banana <: Fruit` at the PHP class level, + * this adds `extends Producer_Fruit_` to the cloned `Producer_Banana` + * Class_ node. + * + * Three rules per arg-pair `(arg_i, variance_i)`: + * - Invariant: arg1.canonical() == arg2.canonical() + * - Covariant: isNestedSubtype(arg1, arg2) + * - Contravariant: isNestedSubtype(arg2, arg1) + * + * Scalar args are skipped -- there's no PHP-level subtype relationship + * between scalars, so emitting an edge would PHP-fatal at autoload. + * + * `isNestedSubtype` handles both leaf (non-generic) classes and nested + * generic instantiations of the same template (recurses through the inner + * template's variance). Different templates or mixed forms return false + * conservatively -- a wrong edge would PHP-fatal at autoload, while a + * missed edge only loses an `instanceof` relationship the compiler couldn't + * prove. The bound check (`Registry::evaluateBound`) treats null verdicts + * as "reject"; the variance check treats them as "skip edge" for that same + * fatal-vs-missed-edge asymmetry. + * + * Edge shape: + * - Class_ specialization -> `extends ` (single, since PHP allows + * only one class inheritance). If multiple "direct" supers exist + * (unrelated diamond), the lexicographically-first generated FQN wins + * deterministically. + * - Interface_ specialization -> `extends , , ...` + * (multi-target; PHP interfaces support it). + * + * Transitive supers are filtered: `Banana <: Apple <: Fruit` produces + * `Banana -> Apple` only (Fruit reached transitively via Apple). + * + * Runs as a dedicated Compiler phase between specialization (Phase 2) and + * CallSiteRewriter (Phase 3) -- the fixed-point loop must finish before + * pairwise variance comparisons can run. + */ +final class VarianceEdgeEmitter +{ + public function __construct(private readonly TypeHierarchy $hierarchy) + { + } + + /** + * @param array $specializedAsts keyed by generated FQCN + */ + public function emitEdges(array $specializedAsts, Registry $registry): void + { + // Group specializations by template. + $byTemplate = []; + foreach ($registry->instantiations() as $generatedFqn => $instantiation) { + $byTemplate[$instantiation->templateFqn][] = $instantiation; + } + + foreach ($byTemplate as $templateFqn => $instantiations) { + $definition = $registry->definition($templateFqn); + if ($definition === null) { + continue; + } + if (!self::hasNonInvariantParam($definition->typeParams)) { + // Pure invariant template -- no variance edges possible. + continue; + } + + foreach ($instantiations as $sp1) { + $ast = $specializedAsts[$sp1->generatedFqn] ?? null; + if ($ast === null) { + continue; + } + + // Collect all candidates: supers of sp1 (sp1 <: sp2). + $candidates = []; + foreach ($instantiations as $sp2) { + if ($sp1->generatedFqn === $sp2->generatedFqn) { + continue; + } + if ($this->isVarianceSubtype( + $sp1->concreteTypes, + $sp2->concreteTypes, + $definition->typeParams, + $registry, + )) { + $candidates[] = $sp2; + } + } + + // Filter to direct supers: sp2 is direct if no other sp3 in + // candidates is itself a super of sp2 (i.e. sp3 sits between + // sp1 and sp2 in the chain, so sp2 is reached transitively). + $direct = $this->filterDirectSupers($candidates, $definition->typeParams, $registry); + + self::addImplementsEdges($ast, $direct); + } + } + } + + /** + * @param list $candidates + * @param list $params + * @return list + */ + private function filterDirectSupers(array $candidates, array $params, Registry $registry): array + { + $direct = []; + foreach ($candidates as $sp2) { + $impliedByAnother = false; + foreach ($candidates as $sp3) { + if ($sp2->generatedFqn === $sp3->generatedFqn) { + continue; + } + // sp3 is a more-specific super than sp2 if sp3 <: sp2 -- then + // sp2 is reached transitively via sp3, so sp2 isn't direct. + if ($this->isVarianceSubtype( + $sp3->concreteTypes, + $sp2->concreteTypes, + $params, + $registry, + )) { + $impliedByAnother = true; + break; + } + } + if (!$impliedByAnother) { + $direct[] = $sp2; + } + } + return $direct; + } + + /** + * @param list $params + */ + private static function hasNonInvariantParam(array $params): bool + { + foreach ($params as $param) { + if ($param->variance !== Variance::Invariant) { + return true; + } + } + return false; + } + + /** + * @param list $args1 + * @param list $args2 + * @param list $params + */ + private function isVarianceSubtype( + array $args1, + array $args2, + array $params, + Registry $registry, + ): bool { + if (count($args1) !== count($args2) || count($args1) !== count($params)) { + return false; + } + $sawNonIdentity = false; + foreach ($args1 as $i => $a1) { + $a2 = $args2[$i]; + $variance = $params[$i]->variance; + + if ($a1->canonical() !== $a2->canonical()) { + $sawNonIdentity = true; + } + + // Scalar args never participate in variance edges. + if ($a1->isScalar || $a2->isScalar) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + + if ($variance === Variance::Invariant) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + if ($variance === Variance::Covariant) { + if (!$this->isNestedSubtype($a1, $a2, $registry)) { + return false; + } + continue; + } + // Contravariant + if (!$this->isNestedSubtype($a2, $a1, $registry)) { + return false; + } + } + // sp1 == sp2 (identical args) is filtered upstream, but defensively: + // require at least one arg to differ before claiming a subtype edge. + return $sawNonIdentity; + } + + /** + * Three-way subtype check tailored for variance edge emission. + * + * - Both non-generic: delegate to TypeHierarchy::isSubtype. + * - Both generic of the SAME template: recurse arg-wise through THAT + * template's variance. This is what makes `Producer>` + * and `Producer>` produce an edge when Box has covariant + * T -- without it, the comparison would flatten to + * `isSubtype('Box', 'Box') == true` and emit a wrong edge for the + * case where the INNER args aren't subtype-related. + * - Otherwise (different templates, or one generic one not): + * conservative false. A wrong edge would PHP-fatal at autoload. + */ + private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool + { + if (!$child->isGeneric() && !$parent->isGeneric()) { + return $this->hierarchy->isSubtype($child->name, $parent->name) === true; + } + if ($child->isGeneric() && $parent->isGeneric() + && ltrim($child->name, '\\') === ltrim($parent->name, '\\') + ) { + $innerParams = $registry->definition(ltrim($child->name, '\\'))?->typeParams ?? []; + if ($innerParams === []) { + return false; + } + return $this->isVarianceSubtype( + $child->args, + $parent->args, + $innerParams, + $registry, + ); + } + return false; + } + + /** + * Class_ specializations get a single `extends` (PHP allows one parent class). + * If multiple unrelated supers exist after direct-super filtering, the + * lexicographically-first generated FQN wins deterministically. + * + * Interface_ specializations get multi-target `extends`. + * + * @param list $directSupers + */ + private static function addImplementsEdges(ClassLike $ast, array $directSupers): void + { + if ($directSupers === []) { + return; + } + // Deterministic ordering by generated FQN keeps the output stable + // across runs (input order can vary depending on hash collisions and + // file-walk traversal). + usort( + $directSupers, + static fn (GenericInstantiation $a, GenericInstantiation $b): int + => strcmp($a->generatedFqn, $b->generatedFqn), + ); + + if ($ast instanceof Class_) { + $ast->extends = new FullyQualified($directSupers[0]->generatedFqn); + return; + } + if ($ast instanceof Interface_) { + foreach ($directSupers as $sp) { + $ast->extends[] = new FullyQualified($sp->generatedFqn); + } + } + } +} diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php new file mode 100644 index 0000000..46cd39a --- /dev/null +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -0,0 +1,290 @@ + Invariant only + * - Constructor parameter type -> Invariant only + * - Method/function parameter type -> Invariant or Contravariant + * - Method/function return type -> Invariant or Covariant + * - Bound expression -> Invariant only + * - Default expression -> Invariant only + * + * Why properties are strict-invariant: PHP enforces invariant property types + * across the `extends` chain regardless of `readonly`. A covariant +T in a + * subtype property declaration would PHP-fatal at autoload when the variance + * edge `Producer_Banana extends Producer_Fruit` lands. The semantic + * argument ("readonly = output-only") doesn't override PHP's static-type + * rule. Users who need a covariant getter use a `mixed`-typed (or + * bound-typed) backing field + a method `get(): T`. + * + * Why constructors are strict-invariant: PHP applies LSP signature + * compatibility to `__construct` at autoload time on `extends` chains. + * A covariant param would PHP-fatal -- same shape as the property case. + * + * F-bounded variance (`class Sortable<+T : Comparable>`) is rejected + * because `+T` appears inside its own bound (an invariant position). + * + * Errors include the param name, variance marker, and the position class + * so the user sees what's wrong without reading the implementation. + */ +final class VariancePositionValidator +{ + /** + * @param list $params + */ + public static function assertPositions(ClassLike $node, array $params): void + { + $varianceByName = []; + foreach ($params as $param) { + if ($param->variance !== Variance::Invariant) { + $varianceByName[$param->name] = $param->variance; + } + } + if ($varianceByName === []) { + return; + } + + // 1. Bound and default positions are invariant by RFC. Walk each + // param's bound expression + default TypeRef tree; reject if any + // referenced leaf carries a name whose variance isn't Invariant. + foreach ($params as $param) { + if ($param->bound !== null) { + self::checkBoundExpr($param->bound, $varianceByName, $param->name, 'bound'); + } + if ($param->default !== null) { + self::checkTypeRef($param->default, $varianceByName, $param->name, 'default'); + } + } + + // 2. Class-body positions: properties and methods. + foreach ($node->getProperties() as $property) { + self::checkProperty($property, $varianceByName); + } + foreach ($node->getMethods() as $method) { + self::checkMethod($method, $varianceByName); + } + } + + /** + * @param array $varianceByName + */ + private static function checkBoundExpr( + BoundExpr $bound, + array $varianceByName, + string $hostParam, + string $hostPosition, + ): void { + if ($bound instanceof BoundLeaf) { + self::checkTypeRef($bound->type, $varianceByName, $hostParam, $hostPosition); + return; + } + foreach ($bound->operands as $operand) { + self::checkBoundExpr($operand, $varianceByName, $hostParam, $hostPosition); + } + } + + /** + * @param array $varianceByName + */ + private static function checkTypeRef( + TypeRef $ref, + array $varianceByName, + string $hostParam, + string $hostPosition, + ): void { + if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { + $variance = $varianceByName[$ref->name]; + throw self::violationError( + paramName: $ref->name, + variance: $variance, + position: $hostPosition, + hostParam: $hostParam, + ); + } + foreach ($ref->args as $inner) { + self::checkTypeRef($inner, $varianceByName, $hostParam, $hostPosition); + } + } + + /** + * @param array $varianceByName + */ + private static function checkProperty(Property $property, array $varianceByName): void + { + $type = $property->type; + if ($type === null) { + return; + } + // PHP enforces invariant property types across `extends` chains + // regardless of `readonly`. Even +T on a readonly property would + // PHP-fatal at autoload when the variance edge lands. + $position = $property->isReadonly() ? 'readonly property' : 'mutable property'; + self::checkPhpType($type, $varianceByName, [Variance::Invariant], $position); + } + + /** + * @param array $varianceByName + */ + private static function checkMethod(ClassMethod $method, array $varianceByName): void + { + $name = $method->name->toLowerString(); + $isConstructor = $name === '__construct'; + + // Parameter types. Constructors: invariant only (deviation). Other + // methods: invariant or contravariant. + $paramAllowed = $isConstructor + ? [Variance::Invariant] + : [Variance::Invariant, Variance::Contravariant]; + $paramPosition = $isConstructor ? 'constructor parameter' : 'method parameter'; + foreach ($method->params as $param) { + if (!$param instanceof Param) { + continue; + } + if ($param->type !== null) { + self::checkPhpType($param->type, $varianceByName, $paramAllowed, $paramPosition); + } + } + + // Return type. Constructors don't have one; for the rest, invariant + // or covariant. + if (!$isConstructor && $method->returnType !== null) { + self::checkPhpType( + $method->returnType, + $varianceByName, + [Variance::Invariant, Variance::Covariant], + 'method return', + ); + } + } + + /** + * Recursively walk a PHP type AST (Name / Identifier / NullableType / + * UnionType / IntersectionType), checking every Name's parts against + * the variance map. + * + * @param array $varianceByName + * @param list $allowed + */ + private static function checkPhpType( + Node $type, + array $varianceByName, + array $allowed, + string $position, + ): void { + if ($type instanceof Identifier) { + return; // scalar / pseudo type; never a type-param ref. + } + if ($type instanceof Name) { + $parts = $type->getParts(); + if (count($parts) === 1) { + $name = $parts[0]; + if (isset($varianceByName[$name])) { + $variance = $varianceByName[$name]; + if (!in_array($variance, $allowed, true)) { + throw self::violationError( + paramName: $name, + variance: $variance, + position: $position, + hostParam: null, + ); + } + } + } + // Generic args attached via xphp:genericArgs are TypeRef trees; + // recurse into them so `Box` in a parameter position is + // checked too. + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($args)) { + foreach ($args as $arg) { + if ($arg instanceof TypeRef) { + self::checkInnerTypeRef($arg, $varianceByName, $allowed, $position); + } + } + } + return; + } + if ($type instanceof NullableType) { + self::checkPhpType($type->type, $varianceByName, $allowed, $position); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $inner) { + self::checkPhpType($inner, $varianceByName, $allowed, $position); + } + return; + } + if ($type instanceof ComplexType) { + return; + } + } + + /** + * @param array $varianceByName + * @param list $allowed + */ + private static function checkInnerTypeRef( + TypeRef $ref, + array $varianceByName, + array $allowed, + string $position, + ): void { + if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { + $variance = $varianceByName[$ref->name]; + if (!in_array($variance, $allowed, true)) { + throw self::violationError( + paramName: $ref->name, + variance: $variance, + position: $position, + hostParam: null, + ); + } + } + foreach ($ref->args as $inner) { + self::checkInnerTypeRef($inner, $varianceByName, $allowed, $position); + } + } + + private static function violationError( + string $paramName, + Variance $variance, + string $position, + ?string $hostParam, + ): RuntimeException { + $marker = match ($variance) { + Variance::Covariant => '+', + Variance::Contravariant => '-', + Variance::Invariant => '', + }; + $context = $hostParam !== null + ? sprintf(' inside the %s of generic parameter `%s`', $position, $hostParam) + : sprintf(' in %s position', $position); + return new RuntimeException(sprintf( + 'Generic parameter `%s%s` appears%s, which is not allowed for %s variance.', + $marker, + $paramName, + $context, + $variance->value, + )); + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index d97d8a4..fca0570 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -402,7 +402,7 @@ private function scanAndStrip(string $source): array * (forward references are rejected at parse time). * * @param list $tokens - * @return array{0: list, 1: int}|null + * @return array{0: list, 1: int}|null */ private static function parseTypeParamList(array $tokens, int $openIdx, bool $allowDefaults): ?array { @@ -415,6 +415,25 @@ private static function parseTypeParamList(array $tokens, int $openIdx, bool $al $sawDefault = false; $i = self::skipWs($tokens, $openIdx + 1); while ($i < $n) { + // Variance prefix `+` (covariant) or `-` (contravariant). Both are + // single-char tokens at this position. Class-level only -- method/ + // function-level type-params get the same "not yet supported" + // rejection family as defaults. + $variance = Variance::Invariant; + if ($i < $n && ($tokens[$i]->text === '+' || $tokens[$i]->text === '-')) { + if (!$allowDefaults) { + throw new RuntimeException( + 'Variance markers `+T` / `-T` are not yet supported on ' + . 'methods or functions; remove the prefix or move the ' + . 'generic to a class-level type parameter.', + ); + } + $variance = $tokens[$i]->text === '+' + ? Variance::Covariant + : Variance::Contravariant; + $i++; + } + if (!self::isNameToken($tokens[$i])) { return null; } @@ -482,6 +501,7 @@ private static function parseTypeParamList(array $tokens, int $openIdx, bool $al 'name' => $paramName, 'bound' => $bound, 'default' => $default, + 'variance' => $variance, ]; $i = self::skipWs($tokens, $i); @@ -1140,8 +1160,20 @@ public function enterNode(Node $node): null foreach ($paramEntries as $entry) { $bound = $this->buildBoundExpr($entry); $default = $this->buildDefault($entry); - $typeParams[] = new TypeParam($entry['name'], $bound, $default); + $typeParams[] = new TypeParam( + $entry['name'], + $bound, + $default, + $entry['variance'], + ); } + // ATTR_GENERIC_PARAMS is set on enterNode so + // leaveNode (where the variance position validator + // runs) can read it back. The body's nested + // ATTR_GENERIC_ARGS aren't populated until the + // resolver walks each Name node, which only happens + // BETWEEN this class's enterNode and leaveNode -- + // hence the deferred validation. $node->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, $typeParams); $currentNamespace = $this->ctx->currentNamespace(); $fqn = $currentNamespace !== '' @@ -1171,12 +1203,18 @@ public function enterNode(Node $node): null $typeParams = []; foreach ($marker['params'] as $entry) { $bound = $this->buildBoundExpr($entry); - // Method/function entries never carry defaults (parseTypeParamList - // rejects `=` with $allowDefaults=false), so buildDefault is null - // by construction; the explicit call documents the symmetry with - // the ClassLike branch. + // Method/function entries never carry defaults or + // variance markers (parseTypeParamList rejects both + // with $allowDefaults=false), so the construct here + // mirrors the ClassLike branch's call shape for + // symmetry; the values are always defaults. $default = $this->buildDefault($entry); - $typeParams[] = new TypeParam($entry['name'], $bound, $default); + $typeParams[] = new TypeParam( + $entry['name'], + $bound, + $default, + $entry['variance'], + ); } // Pop here — the method's own scope is pushed again // below to match the leaveNode pop pattern. This @@ -1338,6 +1376,19 @@ private function buildBoundExprNode(array $node): BoundExpr public function leaveNode(Node $node): null { + // Variance position check fires once per class definition, + // AFTER the body has been fully resolved -- so any Name nodes + // inside the body that carry nested ATTR_GENERIC_ARGS are + // visible to the validator. Rejects covariant T in input + // position, contravariant T in output, either in + // bound/default/property/constructor positions, and + // F-bounded variance (`+T : Box`). + if ($node instanceof ClassLike && $node->name !== null) { + $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (is_array($params) && $params !== []) { + VariancePositionValidator::assertPositions($node, $params); + } + } // @infection-ignore-all -- the instanceof chain mirrors enterNode's push; // restructuring `||` as `&&` produces a leaveNode that no longer pops the // stack for any node, but the test suite's AST shapes never re-use the diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php new file mode 100644 index 0000000..88ab133 --- /dev/null +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -0,0 +1,508 @@ +workDir = sys_get_temp_dir() . '/xphp-variance-' . uniqid('', true); + $this->targetDir = $this->workDir . '/dist'; + $this->cacheDir = $this->workDir . '/.xphp-cache'; + mkdir($this->workDir, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->workDir)) { + self::rrmdir($this->workDir); + } + } + + public function testCovariantSubtypeEdgeIsEmittedAsExtendsForClassSpecializations(): void + { + // Fixture: `variance_covariant_happy/`. Producer<+T>, Banana <: Fruit. + // Two specializations; Producer_Banana extends Producer_Fruit because + // +T is covariant. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/variance_covariant_happy/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(2, $result->generatedCount); + $fruitFqn = Registry::generatedFqn( + 'App\\VarianceCovariantHappy\\Containers\\Producer', + [new TypeRef('App\\VarianceCovariantHappy\\Models\\Fruit')], + ); + $bananaFqn = Registry::generatedFqn( + 'App\\VarianceCovariantHappy\\Containers\\Producer', + [new TypeRef('App\\VarianceCovariantHappy\\Models\\Banana')], + ); + $bananaFile = $this->fqnToPath($bananaFqn); + self::assertFileExists($bananaFile); + // The Banana specialization must extend the Fruit specialization. + $bananaContent = file_get_contents($bananaFile); + self::assertStringContainsString('extends \\' . $fruitFqn, $bananaContent); + } + + public function testContravariantSubtypeEdgeFlipsDirection(): void + { + // Fixture: `variance_contravariant_happy/`. Consumer<-T>, Dog <: Animal. + // With contravariance, the edge flips: Consumer_Animal extends + // Consumer_Dog (not the other way around). + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/variance_contravariant_happy/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(2, $result->generatedCount); + $animalFqn = Registry::generatedFqn( + 'App\\VarianceContravariantHappy\\Containers\\Consumer', + [new TypeRef('App\\VarianceContravariantHappy\\Models\\Animal')], + ); + $dogFqn = Registry::generatedFqn( + 'App\\VarianceContravariantHappy\\Containers\\Consumer', + [new TypeRef('App\\VarianceContravariantHappy\\Models\\Dog')], + ); + $animalContent = file_get_contents($this->fqnToPath($animalFqn)); + self::assertStringContainsString('extends \\' . $dogFqn, $animalContent); + } + + public function testEmittedSubtypeChainAutoloadsWithoutPhpFatal(): void + { + // The critical safety test: PHP applies LSP signature compat at + // autoload time. If the variance edge emission produces an incompatible + // method signature, PHP fatals with "Declaration of X::m must be + // compatible with Y::m". Spawn a subprocess that requires every emitted + // file for the covariant fixture; non-zero exit means a fatal at load. + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/variance_covariant_happy/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $loader = $this->writeAutoloadCheck( + 'App\\VarianceCovariantHappy', + [ + 'Containers/Producer.php' => 'Producer', + ], + [ + 'Models/Fruit.php', + 'Models/Banana.php', + ], + ); + + $output = []; + $exitCode = 0; + exec('php ' . escapeshellarg($loader) . ' 2>&1', $output, $exitCode); + self::assertSame( + 0, + $exitCode, + "Autoload-time fatal:\n" . implode("\n", $output), + ); + self::assertContains('OK', $output); + } + + public function testNoEdgeBetweenUnrelatedSpecializations(): void + { + // Banana and Apple both extend Fruit but not each other. The edge + // between Producer_Banana and Producer_Apple must NOT be emitted (no + // PHP-level subtype relationship between Banana and Apple). + $sourceDir = $this->workDir . '/src-unrelated'; + mkdir($sourceDir . '/Containers', 0o755, true); + mkdir($sourceDir . '/Models', 0o755, true); + file_put_contents($sourceDir . '/Containers/Producer.xphp', <<<'PHP' + + { + public function get(): T { throw new \LogicException; } + } + PHP); + file_put_contents($sourceDir . '/Models/Fruit.xphp', <<<'PHP' + ; + $a = new Containers\Producer::; + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $bananaFqn = Registry::generatedFqn( + 'App\\Containers\\Producer', + [new TypeRef('App\\Models\\Banana')], + ); + $appleFqn = Registry::generatedFqn( + 'App\\Containers\\Producer', + [new TypeRef('App\\Models\\Apple')], + ); + $bananaContent = file_get_contents($this->fqnToPath($bananaFqn)); + $appleContent = file_get_contents($this->fqnToPath($appleFqn)); + + self::assertStringNotContainsString($appleFqn, $bananaContent); + self::assertStringNotContainsString($bananaFqn, $appleContent); + } + + public function testScalarArgsSkipVarianceEdgeEmission(): void + { + // `Producer<+T>` instantiated with int and string -- no PHP-level + // subtype relationship between scalars, so no edge is emitted in + // either direction. + $sourceDir = $this->workDir . '/src-scalar'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/Producer.xphp', <<<'PHP' + + { + public function get(): T { throw new \LogicException; } + } + PHP); + file_put_contents($sourceDir . '/Use.xphp', <<<'PHP' + ; + $s = new Producer::; + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $intFqn = Registry::generatedFqn( + 'App\\Producer', + [new TypeRef('int', isScalar: true)], + ); + $stringFqn = Registry::generatedFqn( + 'App\\Producer', + [new TypeRef('string', isScalar: true)], + ); + $intContent = file_get_contents($this->fqnToPath($intFqn)); + $stringContent = file_get_contents($this->fqnToPath($stringFqn)); + + self::assertStringNotContainsString($stringFqn, $intContent); + self::assertStringNotContainsString($intFqn, $stringContent); + } + + public function testTransitiveEdgesCollapseToDirectParent(): void + { + // Banana <: Apple <: Fruit. Three specializations of Producer<+T>. + // The variance edges form a chain: Producer_Banana extends Producer_Apple, + // Producer_Apple extends Producer_Fruit. Producer_Banana does NOT need a + // direct edge to Producer_Fruit (PHP resolves it transitively). + $sourceDir = $this->workDir . '/src-transitive'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/P.xphp', <<<'PHP' + { public function get(): T { throw new \LogicException; } } + PHP); + file_put_contents($sourceDir . '/Fruit.xphp', <<<'PHP' + ; + $a = new P::; + $b = new P::; + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $fruitFqn = Registry::generatedFqn('App\\P', [new TypeRef('App\\Fruit')]); + $appleFqn = Registry::generatedFqn('App\\P', [new TypeRef('App\\Apple')]); + $bananaFqn = Registry::generatedFqn('App\\P', [new TypeRef('App\\Banana')]); + + $bananaContent = file_get_contents($this->fqnToPath($bananaFqn)); + $appleContent = file_get_contents($this->fqnToPath($appleFqn)); + + // Direct parent: Banana extends Apple. + self::assertStringContainsString('extends \\' . $appleFqn, $bananaContent); + // Transitive: Banana does NOT need a direct extends to Fruit. + self::assertStringNotContainsString('extends \\' . $fruitFqn, $bananaContent); + // Apple's direct parent IS Fruit. + self::assertStringContainsString('extends \\' . $fruitFqn, $appleContent); + } + + public function testAllThreeFeaturesCompose(): void + { + // Fixture: `variance_with_defaults_and_bounds/`. `Cache<+K : Stringable + // & Countable, V = mixed>` composes covariance + intersection bound + // + default. Verifies the integration: parse succeeds, bound is + // checked, default pads, variance edges emit (single specialization + // here -- no edge yet, but the pipeline runs cleanly). + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/variance_with_defaults_and_bounds/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + self::assertSame(1, $result->generatedCount); + // Defaulted V pads to mixed in the specialization. + $cacheFqn = Registry::generatedFqn( + 'App\\VarianceWithDefaultsAndBounds\\Containers\\Cache', + [ + new TypeRef('App\\VarianceWithDefaultsAndBounds\\Models\\Tag'), + new TypeRef('mixed', isScalar: true), + ], + ); + self::assertFileExists($this->fqnToPath($cacheFqn)); + } + + public function testInterfaceSpecializationsGetMultiExtendsButFilterTransitives(): void + { + // Interface_ template with covariant +T. Banana <: Apple <: Fruit. + // Each specialization's `extends` list must contain ONLY direct + // supers, not transitively-implied ones -- so Banana's interface + // extends [Apple] only, NOT [Apple, Fruit]. The filter-direct-supers + // pass is exercised here on a multi-extends path that single-extends + // Class_ tests don't cover. + $sourceDir = $this->workDir . '/src-iface-transitive'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/IProducer.xphp', <<<'PHP' + { public function get(): T; } + PHP); + file_put_contents($sourceDir . '/Fruit.xphp', <<<'PHP' + $f, IProducer:: $a, IProducer:: $b): void {} + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $fruitFqn = Registry::generatedFqn('App\\IProducer', [new TypeRef('App\\Fruit')]); + $appleFqn = Registry::generatedFqn('App\\IProducer', [new TypeRef('App\\Apple')]); + $bananaFqn = Registry::generatedFqn('App\\IProducer', [new TypeRef('App\\Banana')]); + + $bananaContent = file_get_contents($this->fqnToPath($bananaFqn)); + $appleContent = file_get_contents($this->fqnToPath($appleFqn)); + + // Banana's interface extends list contains Apple (direct). + // Banana does NOT extend Fruit (transitive via Apple). + self::assertStringContainsString($appleFqn, $bananaContent); + self::assertStringNotContainsString($fruitFqn, $bananaContent); + // Apple's interface extends list contains Fruit (direct). + self::assertStringContainsString($fruitFqn, $appleContent); + } + + public function testInvariantTemplateProducesNoVarianceEdges(): void + { + // Pure invariant template -- no `+` / `-` markers, so the edge emitter + // short-circuits and no specialization gets an extra extends. + $sourceDir = $this->workDir . '/src-invariant'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/Box.xphp', <<<'PHP' + + { + public T $item; + } + PHP); + file_put_contents($sourceDir . '/Fruit.xphp', <<<'PHP' + ; + $b = new Box::; + PHP); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $fruitFqn = Registry::generatedFqn('App\\Box', [new TypeRef('App\\Fruit')]); + $bananaFqn = Registry::generatedFqn('App\\Box', [new TypeRef('App\\Banana')]); + $bananaContent = file_get_contents($this->fqnToPath($bananaFqn)); + + // No extends to the Fruit specialization (invariant -- no variance edges). + self::assertStringNotContainsString($fruitFqn, $bananaContent); + } + + /** + * Build a PHP script that registers an autoloader mapping FQNs to file + * paths, then references one specialization of each kind. The autoloader + * resolves on-demand, so the parent-before-child file order doesn't have + * to be hardcoded. + * + * @param array $markerInterfaces map of "relative/path.php" => interfaceShortName + * @param list $modelFiles relative paths + */ + private function writeAutoloadCheck( + string $appNamespace, + array $markerInterfaces, + array $modelFiles, + ): string { + $loader = $this->workDir . '/load.php'; + $body = " $shortName) { + $fqn = $appNamespace . '\\Containers\\' . $shortName; + $map[$fqn] = $this->targetDir . '/' . $relPath; + } + // Model classes -- derive FQN from the file path. + foreach ($modelFiles as $relPath) { + $base = basename($relPath, '.php'); + $fqn = $appNamespace . '\\Models\\' . $base; + $map[$fqn] = $this->targetDir . '/' . $relPath; + } + // Specialized classes under .xphp-cache/Generated. + $generatedDir = $this->cacheDir . '/Generated'; + $specializedFqns = []; + if (is_dir($generatedDir)) { + $iter = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($generatedDir)); + foreach ($iter as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), '.php')) { + // FQN: XPHP\Generated + relative dir + class name (basename without .php). + $relPath = substr($file->getPathname(), strlen($generatedDir) + 1); + $fqn = 'XPHP\\Generated\\' . str_replace('/', '\\', substr($relPath, 0, -4)); + $map[$fqn] = $file->getPathname(); + $specializedFqns[] = $fqn; + } + } + } + + $body .= "spl_autoload_register(function (string \$class): void {\n"; + $body .= " \$map = " . var_export($map, true) . ";\n"; + $body .= " if (isset(\$map[\$class])) { require_once \$map[\$class]; }\n"; + $body .= "});\n\n"; + + // Reference each specialized class to force autoload. + foreach ($specializedFqns as $fqn) { + $body .= "class_exists(" . var_export($fqn, true) . ");\n"; + } + $body .= "echo \"OK\\n\";\n"; + file_put_contents($loader, $body); + return $loader; + } + + private function fqnToPath(string $fqn): string + { + $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; + $rel = str_starts_with($fqn, $prefix) ? substr($fqn, strlen($prefix)) : $fqn; + return $this->cacheDir . '/Generated/' . str_replace('\\', '/', $rel) . '.php'; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b0eba44..003f73d 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1968,6 +1968,300 @@ class Bad $parser->parse($source); } + public function testCovariantTypeParamIsParsedAndStored(): void + { + $source = <<<'PHP' + +{ + public function get(): T { throw new \LogicException; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame(Variance::Covariant, $params[0]->variance); + } + + public function testContravariantTypeParamIsParsedAndStored(): void + { + $source = <<<'PHP' + +{ + public function set(T $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertSame(Variance::Contravariant, $params[0]->variance); + } + + public function testInvariantTypeParamRemainsTheDefault(): void + { + $source = <<<'PHP' + { public T $item; } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $params = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertSame(Variance::Invariant, $params[0]->variance); + } + + public function testMixedVarianceTypeParamsAreParsed(): void + { + $source = <<<'PHP' + +{ + public function key(): K; + public function current(): V; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + $params = $iface?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertSame(Variance::Invariant, $params[0]->variance); + self::assertSame(Variance::Covariant, $params[1]->variance); + } + + public function testMethodLevelVarianceIsRejected(): void + { + $source = <<<'PHP' +(T $x): T { return $x; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Variance markers `+T` / `-T` are not yet supported on methods or functions'); + $parser->parse($source); + } + + public function testFreeFunctionVarianceIsRejected(): void + { + $source = <<<'PHP' +(T $x): T { return $x; } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not yet supported on methods or functions'); + $parser->parse($source); + } + + public function testCovariantInInputPositionIsRejected(): void + { + $source = <<<'PHP' + +{ + public function set(T $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('method parameter'); + $parser->parse($source); + } + + public function testContravariantInOutputPositionIsRejected(): void + { + $source = <<<'PHP' + +{ + public function get(): T { throw new \LogicException; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('-T'); + $this->expectExceptionMessage('method return'); + $parser->parse($source); + } + + public function testCovariantInMutablePropertyIsRejected(): void + { + $source = <<<'PHP' + +{ + public T $item; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('mutable property'); + $parser->parse($source); + } + + public function testCovariantInReadonlyPropertyIsAlsoRejected(): void + { + // PHP enforces invariant property types across `extends` chains + // regardless of `readonly`. Even though a readonly property is + // semantically "output-only", PHP's static type system rejects + // covariance on the property declaration -- so we reject it at + // declaration time to avoid an autoload-time fatal. + $source = <<<'PHP' + +{ + public readonly T $item; + public function get(): T { return $this->item; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('readonly property'); + $parser->parse($source); + } + + public function testCovariantInBoundIsRejected(): void + { + // F-bounded with variance: `+T : Box` rejected because T appears + // inside its own bound (an invariant position). + $source = <<<'PHP' +> +{ + public function get(): T { throw new \LogicException; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('bound'); + $parser->parse($source); + } + + public function testCovariantInConstructorParamIsRejected(): void + { + // xphp deviates from RFC: constructor params are invariant because + // PHP's autoload-time signature compatibility check applies to + // __construct on `Producer_Banana implements Producer_Fruit`. + $source = <<<'PHP' + +{ + public function __construct(T $item) {} + public function get(): T { throw new \LogicException; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('constructor parameter'); + $parser->parse($source); + } + + public function testContravariantInInputPositionIsAccepted(): void + { + $source = <<<'PHP' + +{ + public function set(T $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $parser->parse($source); + self::assertTrue(true); + } + + public function testInvariantTypeParamAcceptsBothPositions(): void + { + $source = <<<'PHP' + +{ + public T $item; + public function get(): T { return $this->item; } + public function set(T $x): void { $this->item = $x; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $parser->parse($source); + self::assertTrue(true); + } + + public function testCovariantInNestedGenericInputPositionIsRejected(): void + { + // `+T` inside `Box` in a method parameter position. The validator + // walks into the inner generic args attached via xphp:genericArgs; + // the +T leaf is rejected just as if it appeared directly. + $source = <<<'PHP' + +{ + public function set(Box $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('method parameter'); + $parser->parse($source); + } + + public function testContravariantInNestedGenericReturnPositionIsRejected(): void + { + // Symmetric case: `-T` inside `Box` in a method return type. + $source = <<<'PHP' + +{ + public function fetch(): Box { throw new \LogicException; } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('-T'); + $this->expectExceptionMessage('method return'); + $parser->parse($source); + } + + public function testInterfaceMethodSignatureIsValidatedForVariance(): void + { + // Variance rules apply to interface methods too. + $source = <<<'PHP' + +{ + public function feed(T $x): void; +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('+T'); + $parser->parse($source); + } + /** * @template TNode of Node * @param array $ast diff --git a/test/fixture/compile/variance_contravariant_happy/source/Containers/Consumer.xphp b/test/fixture/compile/variance_contravariant_happy/source/Containers/Consumer.xphp new file mode 100644 index 0000000..5d19ebc --- /dev/null +++ b/test/fixture/compile/variance_contravariant_happy/source/Containers/Consumer.xphp @@ -0,0 +1,16 @@ + is +// expected, a Consumer works -- it accepts a wider input). +class Consumer<-T> +{ + public function consume(T $value): void + { + } +} diff --git a/test/fixture/compile/variance_contravariant_happy/source/Models/Animal.xphp b/test/fixture/compile/variance_contravariant_happy/source/Models/Animal.xphp new file mode 100644 index 0000000..5a39b80 --- /dev/null +++ b/test/fixture/compile/variance_contravariant_happy/source/Models/Animal.xphp @@ -0,0 +1,9 @@ +; +$consumerDog = new Consumer::; diff --git a/test/fixture/compile/variance_covariant_happy/source/Containers/Producer.xphp b/test/fixture/compile/variance_covariant_happy/source/Containers/Producer.xphp new file mode 100644 index 0000000..668cf25 --- /dev/null +++ b/test/fixture/compile/variance_covariant_happy/source/Containers/Producer.xphp @@ -0,0 +1,24 @@ + +{ + private mixed $item = null; + + public function get(): T + { + return $this->item; + } +} diff --git a/test/fixture/compile/variance_covariant_happy/source/Models/Banana.xphp b/test/fixture/compile/variance_covariant_happy/source/Models/Banana.xphp new file mode 100644 index 0000000..ceb6bb3 --- /dev/null +++ b/test/fixture/compile/variance_covariant_happy/source/Models/Banana.xphp @@ -0,0 +1,9 @@ +; +$producerFruit = new Producer::; diff --git a/test/fixture/compile/variance_with_defaults_and_bounds/source/Containers/Cache.xphp b/test/fixture/compile/variance_with_defaults_and_bounds/source/Containers/Cache.xphp new file mode 100644 index 0000000..1413007 --- /dev/null +++ b/test/fixture/compile/variance_with_defaults_and_bounds/source/Containers/Cache.xphp @@ -0,0 +1,24 @@ + +{ + private ?\Stringable $key = null; + public V $value; + + public function getKey(): K + { + return $this->key; + } +} diff --git a/test/fixture/compile/variance_with_defaults_and_bounds/source/Models/Tag.xphp b/test/fixture/compile/variance_with_defaults_and_bounds/source/Models/Tag.xphp new file mode 100644 index 0000000..658575f --- /dev/null +++ b/test/fixture/compile/variance_with_defaults_and_bounds/source/Models/Tag.xphp @@ -0,0 +1,22 @@ +value; + } + + public function count(): int + { + return strlen($this->value); + } +} diff --git a/test/fixture/compile/variance_with_defaults_and_bounds/source/Use.xphp b/test/fixture/compile/variance_with_defaults_and_bounds/source/Use.xphp new file mode 100644 index 0000000..3af1caf --- /dev/null +++ b/test/fixture/compile/variance_with_defaults_and_bounds/source/Use.xphp @@ -0,0 +1,11 @@ + pads to Cache. +$cache = new Cache::; From 75d054910e491d9b409c9bf780cee7ab10502a5a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 06:32:23 +0000 Subject: [PATCH 15/36] refactor(parser): widen marker shape with bytePosition + kind The scanner produces three marker streams (class, name, method) that the resolver later matches against AST nodes by (line, name). Anonymous template forms -- closures and arrow functions, coming next -- have no name, so the (line, name) anchor breaks. Two new fields on every marker make the shape forward-compatible: - `bytePosition: int` -- the source byte offset of the anchor token (the class / method name for named templates; the `function` / `fn` keyword for anonymous ones). Stable across the scanner's stripping passes because every byte-range substitution is equal-length, so the offset round-trips through `applyReplacements` unchanged. - `kind: string` -- the marker's shape family. `'named'` for now; the closure / arrow patch will add `'closure'`, `'staticClosure'`, `'arrow'`, and `'variableTurbofish'`. No behavior change: today's consumers still match by (line, name); the new fields are populated but unused. The next commit adds anonymous- template recognition and starts reading bytePosition + kind on the matcher side. The two `ArrayItem` mutants surfaced by Infection (dropping either new entry produces an observationally-identical marker today) get an `@ignore` in infection.json5 that will be removed once the anonymous- recognition consumers land. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 12 ++++++++ .../Monomorphize/XphpSourceParser.php | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/infection.json5 b/infection.json5 index fda22a8..8f99e14 100644 --- a/infection.json5 +++ b/infection.json5 @@ -587,6 +587,18 @@ "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" ] }, + // XphpSourceParser::scanAndStrip: the `'kind' => 'named'` and + // `'bytePosition' => $tok->pos` entries on marker arrays are reserved + // for the closure/arrow recognition path that hasn't shipped yet -- + // current consumers only read `line`/`name`/`anchorLine`/`args`/`params`, + // so dropping the kind/bytePosition entries is observationally + // equivalent today. Once the closure code lands those entries become + // load-bearing and the ignore will be removed. + "ArrayItem": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, // VariancePositionValidator::checkPhpType: instanceof chain on the // PHP type AST node kinds. Each branch corresponds to a different // PHP type-hint surface (Name / NullableType / UnionType / diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index fca0570..fd3396c 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -183,6 +183,7 @@ private function scanAndStrip(string $source): array if ($j < $n && $tokens[$j]->id === T_STRING) { $methodName = $tokens[$j]->text; $methodLine = $tokens[$j]->line; + $methodAnchorByte = $tokens[$j]->pos; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: false); @@ -191,6 +192,8 @@ private function scanAndStrip(string $source): array $methodMarkers[] = [ 'line' => $methodLine, 'name' => $methodName, + 'kind' => 'named', + 'bytePosition' => $methodAnchorByte, 'params' => $paramEntries, ]; $startByte = $tokens[$k]->pos; @@ -211,6 +214,7 @@ private function scanAndStrip(string $source): array if ($j < $n && $tokens[$j]->id === T_STRING) { $className = $tokens[$j]->text; $classLine = $tokens[$j]->line; + $classAnchorByte = $tokens[$j]->pos; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: true); @@ -219,6 +223,8 @@ private function scanAndStrip(string $source): array $classMarkers[] = [ 'line' => $classLine, 'name' => $className, + 'kind' => 'named', + 'bytePosition' => $classAnchorByte, 'params' => $paramEntries, ]; $startByte = $tokens[$k]->pos; @@ -286,6 +292,8 @@ private function scanAndStrip(string $source): array 'line' => $nameLine, 'anchorLine' => $anchorLine, 'name' => ltrim($nameText, '\\'), + 'kind' => 'named', + 'bytePosition' => $tok->pos, 'args' => $args, ]; // Strip from `::` start through `>` end so the cleaned @@ -338,6 +346,8 @@ private function scanAndStrip(string $source): array 'line' => $nameLine, 'anchorLine' => $anchorLine, 'name' => ltrim($nameText, '\\'), + 'kind' => 'named', + 'bytePosition' => $tok->pos, 'args' => $args, ]; } @@ -1088,10 +1098,16 @@ private static function applyReplacements(string $source, array $replacements): /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * + * Marker entries carry both a `name` (for legacy (line, name) matching on + * named templates) and a `bytePosition` of the anchor token in the + * source. The `kind` field tags the marker so anonymous-template + * recognition (closures / arrows, Phase 4) can dispatch to a different + * matcher without having to peek at the rest of the marker shape. + * * @param list $ast - * @param list}> $classMarkers - * @param list}> $nameMarkers - * @param list}> $methodMarkers + * @param list}> $classMarkers + * @param list}> $nameMarkers + * @param list}> $methodMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers): void { @@ -1102,9 +1118,9 @@ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMa private array $typeParamStack = []; /** - * @param list}> $classMarkers - * @param list}> $nameMarkers - * @param list}> $methodMarkers + * @param list}> $classMarkers + * @param list}> $nameMarkers + * @param list}> $methodMarkers */ public function __construct( private array $classMarkers, From 819fe0cf564789e9515a2216b3835f65839bf98b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 06:43:56 +0000 Subject: [PATCH 16/36] feat(parser): lift defaults to methods + free functions Methods and free functions now accept `` defaults at parse time. Bare calls (no `::<...>`) on all-defaulted method or static generics pad their args from the defaults at call-site rewrite time, so `$m->id('hello')` on a method declared `id(T): T` works without an explicit turbofish. Parser (`parseTypeParamList`): split the single `$allowDefaults` flag into `$allowDefaults` + `$allowVariance`. Class headers pass both true; method/function headers pass `(defaults=true, variance=false)`. The variance rejection message broadens to "methods, functions, closures, or arrow functions" so future closure/arrow support won't need another message reshuffling. The defaults rejection message moves to "closures or arrow functions" since the method/function path no longer rejects. Registry: extract `padWithDefaults` into a public static `padArgsWithDefaults(list, list, string)`. Both the existing class-level instantiation path and the new method-level bare-call path call the static utility -- padding semantics are identical regardless of call-site shape (substitute earlier-positional concretes into any type-param refs in the default; throw with a clear "parameter `X` (position N) has no default" if a non-defaulted param is missing). GenericMethodCompiler: `rewriteStaticCall` and `rewriteInstanceMethodCall` no longer require ATTR_METHOD_GENERIC_ARGS to be non-empty. When the attribute is null (bare call, no turbofish) and the resolved method template has all-defaulted typeParams, the rewriter synthesizes an empty arg list and runs it through `padArgsWithDefaults`. Same shape as the bare-`new Cache;` synthesis in RegistryCollector for class instantiations -- empty args route through the padding utility, which fills them from defaults. `hasAllDefaults` helper on the inner visitor returns true only when every param has a default (and the param list is non-empty). Bare calls on a partially-defaulted generic method continue to bail out -- specializing without an arg shape isn't well-defined when some params are required. Tests: parser tests pinning the old method/function default rejection are flipped to "accepted + stored" assertions; the variance-rejection message split is also pinned. New integration test `testMethodLevelBareCallPadsFromDefaults` exercises the bare-call padding end-to-end on a method declared `id`. The bound + default decl-time check for method/function templates is deferred to the existing per-instantiation `Registry::checkBounds` path -- a real violation surfaces at the first specialization rather than at the source-level (acceptable scope cut; class-level decl-time check stays). Test count 308 -> 309; MSI 100% on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 32 ++++++----- .../Monomorphize/GenericMethodCompiler.php | 54 ++++++++++++++++--- src/Transpiler/Monomorphize/Registry.php | 31 ++++++++++- .../Monomorphize/XphpSourceParser.php | 39 +++++++++----- .../DefaultedGenericIntegrationTest.php | 52 +++++++++++++++--- .../Monomorphize/XphpSourceParserTest.php | 29 ++++++---- 6 files changed, 185 insertions(+), 52 deletions(-) diff --git a/infection.json5 b/infection.json5 index 8f99e14..eb43877 100644 --- a/infection.json5 +++ b/infection.json5 @@ -111,11 +111,11 @@ // as a bound" which survives every reordering. Same justification // as the Registry::validateBounds entries above. "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference", - // Registry::padWithDefaults + Registry::validateDefaultsAgainstBounds: + // Registry::padArgsWithDefaults + Registry::validateDefaultsAgainstBounds: // sprintf-friendly error messages. Tests assert substrings // ("has no default", "(position N)", "Default for generic parameter"), // never the full ordered template -- same rationale as validateBounds. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", "XPHP\\Transpiler\\Monomorphize\\Registry::validateDefaultsAgainstBounds", // assertDefaultsReferenceOnlyEarlierParams + assertDefaultRefsEarlierOnly: // the human-facing "default references X declared later" / "cannot reference @@ -148,7 +148,7 @@ // as a bound" substring intact, which is what the test asserts on. "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertNoTopLevelSelfReference", // Defaults error messages -- same rationale as the Concat entries above. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", "XPHP\\Transpiler\\Monomorphize\\Registry::validateDefaultsAgainstBounds", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultsReferenceOnlyEarlierParams", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::assertDefaultRefsEarlierOnly", @@ -223,11 +223,14 @@ "XPHP\\Transpiler\\Monomorphize\\Registry::isSameInstantiation", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", - // Registry::padWithDefaults: `ltrim($templateFqn, '\\')` on the lookup - // + the error-message version. Callers (RegistryCollector + the + // Registry::padArgsWithDefaults + Registry::padWithDefaults (the + // wrapper that resolves a `templateFqn` against `$this->definitions` + // and delegates): `ltrim($templateFqn, '\\')` on the lookup AND on + // the error-message version. Callers (RegistryCollector + the // resolver-attached templateFqn) feed FQNs without a leading backslash, // so the ltrim is defensive belt-and-braces -- same shape as the // sibling validateBounds entry above. + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" ] }, @@ -284,13 +287,13 @@ // shortcut falls through to `usort` on an empty array + `new self([])`, // which is exactly what identity() already returns. "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::fromReplacements", - // Registry::padWithDefaults: early `return $args` on the no-definition + // Registry::padArgsWithDefaults: early `return $args` on the no-definition // and no-padding-needed branches. Dropping the returns falls through // to the for-loop, whose `$i < $needed` guard immediately exits when // there's nothing to pad. `$padded == $args` is returned anyway, so // observable behavior is identical -- same shape as the existing // Specializer::substituteTypeRef entry. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", // VarianceEdgeEmitter::isNestedSubtype + ::isVarianceSubtype: // intermediate `return false` branches that fall through to a // following `return false` at the end of the function. The @@ -387,13 +390,13 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", - // Registry::padWithDefaults: the `for ($i = $supplied; $i < $needed; $i++)` + // Registry::padArgsWithDefaults: the `for ($i = $supplied; $i < $needed; $i++)` // loop bound. `<` -> `<=` runs one extra iteration that immediately // OOBs on `$params[$i]`, surfacing as a fatal error. Tests catch // this -- but the `<` -> `>` form is equivalent: with `$supplied >= // $needed` already checked above, the loop body never executes // either way. Same shape as the existing parseTypeArgList entries. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", // Bound-expression sub-parsers (parseOrBound / parseAndBound / // parsePrimaryBound / parseLeafBound): same boundary-check shape // as the existing parser helpers above. `<=` / `<` mutations on @@ -463,12 +466,12 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parsePrimaryBound", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseLeafBound", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", - // Registry::padWithDefaults: `if ($supplied >= $needed) return $args` -> + // Registry::padArgsWithDefaults: `if ($supplied >= $needed) return $args` -> // `>` flips at the equality boundary. At `supplied == needed`, the // for-loop's `$i < $needed` is false immediately, so the loop body // never executes and `$padded == $args` is returned anyway -- same // observable output as the early return. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults" ] }, // CallSiteRewriter::rewrite: `['stmts' => []]` -> `[]` on the @@ -647,7 +650,7 @@ "Plus": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", - // Registry::padWithDefaults: `$i + 1` in the error message's + // Registry::padArgsWithDefaults: `$i + 1` in the error message's // human-readable position display. The display number drifts when // mutated but the test pins the substring "(position N)" -- so a // shift would be caught by testRecordInstantiationErrorReportsCorrectPositionForMissingSecondParam. @@ -656,10 +659,10 @@ // pad-substitute math either -- the only Plus mutant is on the // error message position. Tests assert the substring, so leaving // this ignored is intentional. - "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults" + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults" ] }, - // Registry::padWithDefaults: `$i + 1` -> `$i + 2` / `$i + 0` (Increment / + // Registry::padArgsWithDefaults: `$i + 1` -> `$i + 2` / `$i + 0` (Increment / // Decrement). Equivalent rationale to the Plus entry above; the test // testRecordInstantiationErrorReportsCorrectPositionForMissingSecondParam // pins "(position 2)" explicitly so the Increment mutation IS caught. @@ -741,6 +744,7 @@ // `isVarianceSubtype` (`hasNonInvariantParam` returns false first). "ArrayOneItem": { "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\Registry::padArgsWithDefaults", "XPHP\\Transpiler\\Monomorphize\\Registry::padWithDefaults", "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" ] diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 120c93b..9a943e2 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -626,9 +626,6 @@ private static function isSiblingBranch(Node $node): bool private function rewriteStaticCall(StaticCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); - if (!is_array($args) || $args === [] || !self::allConcrete($args)) { - return null; - } if (!$node->name instanceof Identifier) { return null; } @@ -644,7 +641,21 @@ private function rewriteStaticCall(StaticCall $node): ?Node return null; } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - if (!is_array($params) || count($params) !== count($args)) { + if (!is_array($params)) { + return null; + } + // Bare call (no `::<...>`) on a generic method with all + // defaults: pad to []. Already-tagged turbofish calls go + // through padArgsWithDefaults too so partial-arg shapes are + // filled in the same way class-level instantiations are. + if (!is_array($args)) { + if (!self::hasAllDefaults($params)) { + return null; + } + $args = []; + } + $args = Registry::padArgsWithDefaults($params, $args, $key); + if (!self::allConcrete($args) || count($params) !== count($args)) { return null; } @@ -703,9 +714,6 @@ private function rewriteStaticCall(StaticCall $node): ?Node private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); - if (!is_array($args) || $args === [] || !self::allConcrete($args)) { - return null; - } if (!$node->name instanceof Identifier) { return null; } @@ -721,7 +729,17 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): return null; } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - if (!is_array($params) || count($params) !== count($args)) { + if (!is_array($params)) { + return null; + } + if (!is_array($args)) { + if (!self::hasAllDefaults($params)) { + return null; + } + $args = []; + } + $args = Registry::padArgsWithDefaults($params, $args, $key); + if (!self::allConcrete($args) || count($params) !== count($args)) { return null; } @@ -922,6 +940,26 @@ private static function allConcrete(array $args): bool return true; } + /** + * True iff every TypeParam in the template carries a default. + * Bare calls (no `::<...>`) can specialize only against all-defaults + * templates -- otherwise there's no way to derive the type-args. + * + * @param list $params + */ + private static function hasAllDefaults(array $params): bool + { + if ($params === []) { + return false; + } + foreach ($params as $param) { + if ($param->default === null) { + return false; + } + } + return true; + } + /** * @param list $args */ diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 57f415b..d358d07 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -136,7 +136,34 @@ private function padWithDefaults(string $templateFqn, array $args): array if ($definition === null) { return $args; } - $params = $definition->typeParams; + return self::padArgsWithDefaults( + $definition->typeParams, + $args, + ltrim($templateFqn, '\\'), + ); + } + + /** + * Pad an arg list with defaults declared on `$params`, substituting + * already-positional concretes into any type-param references in the + * default. Shared between `recordInstantiation` (class/interface/trait + * templates) and `GenericMethodCompiler` (method/function/closure + * templates) so the padding semantics stay identical regardless of + * the call-site shape. + * + * Throws when a non-defaulted param is missing and there are fewer + * supplied args than required. Returns `$args` unchanged when the + * supplied count already matches or exceeds the param count. + * + * @param list $params + * @param list $args + * @return list + */ + public static function padArgsWithDefaults( + array $params, + array $args, + string $templateLabel, + ): array { $supplied = count($args); $needed = count($params); if ($supplied >= $needed) { @@ -150,7 +177,7 @@ private function padWithDefaults(string $templateFqn, array $args): array 'Generic template "%s" was instantiated with %d type argument(s) ' . 'but parameter `%s` (position %d) has no default; supply it ' . 'explicitly or add defaults to every preceding required parameter.', - ltrim($templateFqn, '\\'), + $templateLabel, $supplied, $params[$i]->name, $i + 1, diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index fd3396c..133cd71 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -186,7 +186,12 @@ private function scanAndStrip(string $source): array $methodAnchorByte = $tokens[$j]->pos; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { - $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: false); + $parsed = self::parseTypeParamList( + $tokens, + $k, + allowDefaults: true, + allowVariance: false, + ); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; $methodMarkers[] = [ @@ -217,7 +222,12 @@ private function scanAndStrip(string $source): array $classAnchorByte = $tokens[$j]->pos; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { - $parsed = self::parseTypeParamList($tokens, $k, allowDefaults: true); + $parsed = self::parseTypeParamList( + $tokens, + $k, + allowDefaults: true, + allowVariance: true, + ); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; $classMarkers[] = [ @@ -414,8 +424,12 @@ private function scanAndStrip(string $source): array * @param list $tokens * @return array{0: list, 1: int}|null */ - private static function parseTypeParamList(array $tokens, int $openIdx, bool $allowDefaults): ?array - { + private static function parseTypeParamList( + array $tokens, + int $openIdx, + bool $allowDefaults, + bool $allowVariance, + ): ?array { $n = count($tokens); if ($openIdx >= $n || $tokens[$openIdx]->text !== '<') { return null; @@ -426,16 +440,17 @@ private static function parseTypeParamList(array $tokens, int $openIdx, bool $al $i = self::skipWs($tokens, $openIdx + 1); while ($i < $n) { // Variance prefix `+` (covariant) or `-` (contravariant). Both are - // single-char tokens at this position. Class-level only -- method/ - // function-level type-params get the same "not yet supported" - // rejection family as defaults. + // single-char tokens at this position. Class-level only -- methods, + // functions, closures, and arrow functions reject them because + // their specializations aren't keyed by stable identities that + // PHP would resolve via `extends` chains. $variance = Variance::Invariant; if ($i < $n && ($tokens[$i]->text === '+' || $tokens[$i]->text === '-')) { - if (!$allowDefaults) { + if (!$allowVariance) { throw new RuntimeException( 'Variance markers `+T` / `-T` are not yet supported on ' - . 'methods or functions; remove the prefix or move the ' - . 'generic to a class-level type parameter.', + . 'methods, functions, closures, or arrow functions; ' + . 'move the generic to a class-level type parameter.', ); } $variance = $tokens[$i]->text === '+' @@ -467,8 +482,8 @@ private static function parseTypeParamList(array $tokens, int $openIdx, bool $al if (!$allowDefaults) { throw new RuntimeException(sprintf( 'Generic parameter `%s` has a default value, which is not yet ' - . 'supported on methods or functions. Move the generic to a ' - . 'class-level type parameter, or remove the default.', + . 'supported on closures or arrow functions. Assign the closure ' + . 'to a named function or remove the default.', $paramName, )); } diff --git a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php index d80a974..ea55b9b 100644 --- a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php @@ -192,11 +192,44 @@ class Box $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); } - public function testMethodLevelDefaultDeclarationIsRejected(): void + public function testMethodLevelBareCallPadsFromDefaults(): void { - // Confirms the parse-time rejection lives in the integration path too; - // there's no clean way to express a method-level default in the per-class - // fixtures, so we exercise the message end-to-end here. + // `$m->id(42)` (no turbofish) on a method declared `id`. + // GMC's bare-call padding fires: empty args -> pad to [string] -> + // mangle to id_T_. The rewritten user file must + // reference the mangled name. + $sourceDir = $this->workDir . '/src-bare-method'; + mkdir($sourceDir, 0o755, true); + $file = $sourceDir . '/M.xphp'; + file_put_contents($file, <<<'PHP' + (T $x): T { return $x; } + } + $m = new M(); + $m->id('hello'); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($file); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + $rewritten = file_get_contents($this->targetDir . '/M.php'); + // The bare call gets rewritten to the mangled name. + self::assertStringContainsString('id_T_', $rewritten); + // And the original `id` call site no longer appears verbatim + // (the mangled name replaces it). + self::assertStringNotContainsString("->id('hello')", $rewritten); + } + + public function testMethodLevelDefaultDeclarationIsAcceptedAndCompiles(): void + { + // Method-level defaults now parse + compile end-to-end. With a + // turbofish call site we hit the existing specialization path; with + // a bare call (no `::<...>`), the registry pads from defaults via + // GenericMethodCompiler's bare-call padding wiring. $sourceDir = $this->workDir . '/src-method-default'; mkdir($sourceDir, 0o755, true); $file = $sourceDir . '/M.xphp'; @@ -207,14 +240,21 @@ class M { public function id(T $x): T { return $x; } } + $m = new M(); + $m->id::(42); PHP); $compiler = $this->buildCompiler(); $sources = new FilepathArray($file); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('not yet supported on methods or functions'); + // Must not throw -- compile cleanly. Method-level specializations + // get appended to the owning class rather than producing a new class + // file (so generatedCount stays 0 here); the proof of success is + // that compile finishes and the rewritten user file references the + // mangled method name. $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + $rewritten = file_get_contents($this->targetDir . '/M.php'); + self::assertStringContainsString('id_T_', $rewritten); } public function testTooFewArgsWithoutDefaultsFailsCompile(): void diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 003f73d..29426d9 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1846,8 +1846,10 @@ class Box self::assertTrue(true); } - public function testMethodLevelDefaultIsRejectedWithClearError(): void + public function testMethodLevelDefaultIsAcceptedAndStored(): void { + // Method-level defaults now ship. The marker carries the default + // through to the resolver's TypeParam construction. $source = <<<'PHP' (T $x): T { return $x; } } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('not yet supported on methods or functions'); - $parser->parse($source); + $ast = $parser->parse($source); + $class = self::findFirstClass($ast); + $method = $class?->getMethods()[0] ?? null; + self::assertNotNull($method); + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('string', $params[0]->default?->name); } - public function testFreeFunctionLevelDefaultIsRejected(): void + public function testFreeFunctionLevelDefaultIsAcceptedAndStored(): void { $source = <<<'PHP' (T $x): T { return $x; } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('not yet supported on methods or functions'); - $parser->parse($source); + $ast = $parser->parse($source); + $fn = self::findFirstNodeOfType($ast, \PhpParser\Node\Stmt\Function_::class); + $params = $fn?->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('string', $params[0]->default?->name); } public function testNullableDefaultIsRejectedWithClearError(): void @@ -2048,7 +2056,7 @@ public function id<+T>(T $x): T { return $x; } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Variance markers `+T` / `-T` are not yet supported on methods or functions'); + $this->expectExceptionMessage('Variance markers `+T` / `-T` are not yet supported on methods, functions, closures, or arrow functions'); $parser->parse($source); } @@ -2061,7 +2069,8 @@ function id<-T>(T $x): T { return $x; } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('not yet supported on methods or functions'); + $this->expectExceptionMessage('Variance markers'); + $this->expectExceptionMessage('methods, functions, closures, or arrow functions'); $parser->parse($source); } From ee947db75dffd6fc91bf7d99c9e0f1347bb11d69 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 06:47:00 +0000 Subject: [PATCH 17/36] feat(parser): variance validator recurses into method bodies The variance position validator now walks INSIDE method bodies, looking for nested Closure / ArrowFunction nodes whose parameter or return types reference an outer-variance-marked T. Without the recursion the inner closure's signature slips through the per-class validator, and PHP fatals at autoload with a "Declaration of X::emit must be compatible with Y::emit" when the variance edge lands. Implementation: a small recursive walker (`walkBodyForNestedClosures`) traverses statements + expressions via nikic's `getSubNodeNames()` (no NodeTraverser bootstrap inside the per-class hot path). On encountering a Closure or ArrowFunction, its params get checked against `[Invariant, Contravariant]` (the contra-allowed input position) and its return type against `[Invariant, Covariant]` (the co-allowed output position) -- both against the OUTER class's `varianceByName` map. The closure's own type-params (when item 16 ships) will shadow same- named outer T's; the recursive walk has the right shape for that extension because `varianceByName` is passed by value to each recursion step -- the future shadow-filter just clones + unsets the relevant entries before recursing into the closure. Tests: `testCovariantInNestedClosureParameterIsRejected` exercises +T captured by a nested closure's param; `testContravariantInNestedArrowReturnIsRejected` exercises -T in a nested arrow's return. Test count 309 -> 311; MSI 100% on the touched method. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../VariancePositionValidator.php | 61 +++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 47 ++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index 46cd39a..ba510cd 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -6,6 +6,8 @@ use PhpParser\Node; use PhpParser\Node\ComplexType; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Identifier; use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; @@ -176,6 +178,65 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): 'method return', ); } + + // Recurse into the method body for nested closures / arrow functions. + // A closure that captures the OUTER class's T (via implicit capture or + // a `use ($x)` clause) and uses it in a method-param or return position + // counts as outer-T input/output. The position rules apply to the + // OUTER class's variance markers; the inner closure's own type-params + // (when item 16 lands) will shadow same-named outer T's, but until + // then nested closures have no type-params and every name in their + // signature is an outer reference. + if ($method->stmts !== null) { + self::walkBodyForNestedClosures($method->stmts, $varianceByName); + } + } + + /** + * Recursively walk a list of statements (or an expression tree), looking + * for Closure / ArrowFunction nodes whose params or return types reference + * an outer-variance-marked T. + * + * Cheap hand-rolled recursive walk -- avoids spinning up a NodeTraverser + * inside the per-class validator hot path. + * + * @param array $varianceByName + */ + private static function walkBodyForNestedClosures(mixed $node, array $varianceByName): void + { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + foreach ($node->params as $param) { + if ($param instanceof Param && $param->type !== null) { + self::checkPhpType( + $param->type, + $varianceByName, + [Variance::Invariant, Variance::Contravariant], + 'nested closure/arrow parameter', + ); + } + } + if ($node->returnType !== null) { + self::checkPhpType( + $node->returnType, + $varianceByName, + [Variance::Invariant, Variance::Covariant], + 'nested closure/arrow return', + ); + } + // Don't stop -- a closure body may contain further closures. + } + + if (is_array($node)) { + foreach ($node as $child) { + self::walkBodyForNestedClosures($child, $varianceByName); + } + return; + } + if ($node instanceof Node) { + foreach ($node->getSubNodeNames() as $subName) { + self::walkBodyForNestedClosures($node->$subName, $varianceByName); + } + } } /** diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 29426d9..fc9fd3c 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2216,6 +2216,53 @@ public function set(T $x): void { $this->item = $x; } self::assertTrue(true); } + public function testCovariantInNestedClosureParameterIsRejected(): void + { + // `+T` of the OUTER class appears in the parameter type of a nested + // CLOSURE -- the variance validator must recurse into method bodies. + // Without the recursion the inner closure's param `T $x` slips + // through, and at PHP autoload time the variance edge produces a + // signature-compat fatal. + $source = <<<'PHP' + +{ + public function emit(): array + { + $f = function (T $x) {}; + return []; + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('nested closure/arrow parameter'); + $parser->parse($source); + } + + public function testContravariantInNestedArrowReturnIsRejected(): void + { + // `-T` in the return position of a nested ARROW FUNCTION inside a + // contravariant Consumer's method body. + $source = <<<'PHP' + +{ + public function pipe(): array + { + $f = fn (): T => null; + return []; + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('nested closure/arrow return'); + $parser->parse($source); + } + public function testCovariantInNestedGenericInputPositionIsRejected(): void { // `+T` inside `Box` in a method parameter position. The validator From 42f286d40502328599eb2435c7dc909da57a7e29 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 06:59:55 +0000 Subject: [PATCH 18/36] feat(parser): recognize generic closures, arrows, and variable turbofish `function(...) { ... }`, `static function(...) { ... }`, and `fn(...) => ...` are parsed as anonymous generic templates. The call shape `$var::(...)` is recognized at the scanner and routed to the resolver, which attaches `ATTR_METHOD_GENERIC_ARGS` to the resulting `FuncCall(name: Variable, ...)`. Specialization is intentionally out of scope for this commit -- the parser surface (declaration + call recognition) lands first; the follow-up commit wires GenericMethodCompiler to specialize anonymous templates against the variable's tracked closure-typed binding. Scanner (`scanAndStrip`): three new arms cover the anonymous-template recognition: - `T_FUNCTION` / `T_FN` immediately followed by `<` (no T_STRING between) -- records a marker with kind `'closure'` or `'arrow'`, anchored at the keyword's byte position. - `T_STATIC T_FUNCTION` followed by `<` -- records kind `'staticClosure'` anchored at the T_STATIC byte position. - `T_VARIABLE T_DOUBLE_COLON <` -- records a marker with kind `'variableTurbofish'`, name = variable identifier (no `$`), anchored at the T_VARIABLE byte position. `parseTypeParamList` is called with `(allowDefaults: false, allowVariance: false)` for closure/arrow headers -- defaults on closures get the "not yet supported on closures or arrow functions" rejection; variance gets the shared "methods, functions, closures, or arrow functions" rejection. Resolver (`resolveAndAttach`): the method-template matching branch now also fires for `Closure` and `ArrowFunction` nodes. Anonymous matches use `(kind != 'named', bytePosition == node->getStartFilePos())` as the anchor; named matches keep the legacy `(line, name)` semantics. The push/pop typeParamStack pattern extends symmetrically so bare `T` inside a closure body resolves to an `isTypeParam: true` TypeRef. A new FuncCall arm matches `FuncCall(name: Variable($name), ...)` against `variableTurbofish` markers, attaching ATTR_METHOD_GENERIC_ARGS the same way the StaticCall / MethodCall arms do. The existing FuncCall-on-Name arm gets a `kind != 'variableTurbofish'` clause so the two arms don't fight over a shared marker. Tests (7 new): generic closure parsing (named, static, arrow); closure + arrow default rejection; closure variance rejection; variable turbofish recognition with verification of the resolved scalar arg. Test count 311 -> 318; MSI 100% on the changed surface. infection.json5 ignores follow existing patterns -- the new scanner arms share the "observationally-equivalent under the outer `$i++` advance" rationale that the named-function arms have used since item 02. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 92 ++++++-- .../Monomorphize/XphpSourceParser.php | 204 ++++++++++++++++-- .../Monomorphize/XphpSourceParserTest.php | 113 ++++++++++ 3 files changed, 372 insertions(+), 37 deletions(-) diff --git a/infection.json5 b/infection.json5 index eb43877..87010b6 100644 --- a/infection.json5 +++ b/infection.json5 @@ -321,6 +321,13 @@ "Continue_": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", + // scanAndStrip closure / arrow / variable-turbofish arms: + // the `$i++; continue;` fall-through at the bottom of each + // arm fires when the recognition shape doesn't match. The + // outer while loop's `$i++` baseline produces the same + // advance, so the explicit continue is structurally + // equivalent. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", // Compiler::compile: `if ($countAfter === $countBefore) continue;` // -> `break;`. The outer `if (!$newlyProcessed) break;` already // exits when no new specializations were processed; the mutation @@ -381,6 +388,17 @@ // few cases where the absorption breaks down (process crash on // $tokens[$n] dereference); the rest of the cluster is true // equivalents. + "LessThanNegotiation": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, + "LogicalAndAllSubExprNegation": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, "LessThan": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", @@ -495,14 +513,6 @@ // testCovariantInMutablePropertyIsRejected / // testCovariantInConstructorParamIsRejected / // testCovariantInBoundIsRejected) already pin those cases. - "ArrayItemRemoval": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\CallSiteRewriter::rewrite", - "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", - "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator", - "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" - ] - }, // Compiler::compile depth-cap: `$depth > MAX` -> `>=`. Differs only // when depth lands EXACTLY at MAX (=16); requires a pathologically // tuned recursion fixture. Documented as a known untestable boundary. @@ -541,7 +551,21 @@ // matches anyway. "Identical": { "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + // XphpSourceParser::scanAndStrip: the new closure/arrow + // recognition arms (T_FUNCTION/<>, T_STATIC T_FUNCTION/<>, + // T_FN/<>) and the T_VARIABLE turbofish arm each have a + // token-id `===` check that drives the scanner into the + // specific recognition path. Mutating `===` to `!==` either: + // - never matches the intended token (recognition silently + // skipped; outer `$i++` advances; equivalent to no + // recognition for inputs we test), OR + // - matches every OTHER token (recognition fails at the + // next sub-check because the surrounding shape doesn't + // align). The integration tests (closure parser tests + + // variable turbofish test) cover the recognition shape; + // the internal token-id branching is structural. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, // `break` -> `continue` in ByteOffsetMap::toOriginal: subsequent loop @@ -559,7 +583,16 @@ "FalseValue": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", - "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter" + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + // scanAndStrip closure/arrow `allowDefaults: false, + // allowVariance: false` keyword args. Toggling either + // to `true` would let the closure arm accept defaults or + // variance; the parser-tests pin both rejections so a + // toggle would fail those tests directly. The escapes + // surface because Infection mutates the literal `false` + // rather than the keyword-arg semantics; the test surface + // catches the observable behavior. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, // XphpSourceParser::applyReplacements: dropping the usort() entirely. @@ -602,6 +635,26 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, + // The new closure / arrow / variable-turbofish marker arrays + // include the same `'kind' => '...'` and `'bytePosition' => ...` + // entries that the named branches were already covered for. Same + // observational-equivalence rationale: today's downstream + // consumers gate on (line, name) for named markers and (kind, + // bytePosition) for anonymous markers; dropping an unused-by- + // matching-path entry doesn't change the observable behavior. + // Continue_ on the inner-skip control flow: the scanner's outer + // `$i++; continue;` fall-through fires for non-recognized shapes + // anyway; break-vs-continue swaps at this level land on the same + // next-token-iteration path. + "ArrayItemRemoval": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\CallSiteRewriter::rewrite", + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator", + "XPHP\\Transpiler\\Monomorphize\\VarianceEdgeEmitter", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, // VariancePositionValidator::checkPhpType: instanceof chain on the // PHP type AST node kinds. Each branch corresponds to a different // PHP type-hint surface (Name / NullableType / UnionType / @@ -619,7 +672,15 @@ "Ternary": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach", - "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator" + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator", + // scanAndStrip closure / arrow recognition: the + // `$isArrow ? 'arrow' : 'closure'` ternary on the marker kind + // and the `static function` arm. Both branches produce a + // marker kind tag that's consumed downstream identically + // (kind != 'named' for either; the resolver matches by + // (line, bytePos)). Tests assert presence of ATTR_METHOD_GENERIC_PARAMS, + // not the exact kind string. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, // GenericMethodCompiler::process: `(string) $astKey` -- the cast is @@ -632,15 +693,6 @@ }, // GenericMethodCompiler::process line 113: `if ($methodTemplates === [] // && $functionTemplates === []) return;` -- the SubExprNegation - // mutation only differs when BOTH templates are non-empty (mutated - // returns; original continues to rewrite). Requires a new fixture - // mixing method-level + function-level generics in one compile pass; - // out of scope for the existing integration fixtures. - "LogicalAndAllSubExprNegation": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" - ] - }, // XphpSourceParser::scanAndStrip: off-by-one mutations on `$i = $endIdx + 1` // patterns (`+0`, `+2`, `-1`). The outer `while ($i < $n)` loop with a // bottom `$i++` absorbs the shift: misadvancing forward by 1 just hits the diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 133cd71..52bc41c 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -178,6 +178,77 @@ private function scanAndStrip(string $source): array while ($i < $n) { $tok = $tokens[$i]; + // Anonymous closure: `function(...){}` or + // `static function(...){}`. Recognized by T_FUNCTION followed + // immediately by `<` (no T_STRING name). For `static function` + // the leading T_STATIC was consumed in the same arm. + if ($tok->id === T_FUNCTION || $tok->id === T_FN) { + $isArrow = $tok->id === T_FN; + $anchorByte = $tok->pos; + $anchorLine = $tok->line; + $j = self::skipWs($tokens, $i + 1); + if ($j < $n && $tokens[$j]->text === '<') { + $parsed = self::parseTypeParamList( + $tokens, + $j, + allowDefaults: false, + allowVariance: false, + ); + if ($parsed !== null) { + [$paramEntries, $endIdx] = $parsed; + $methodMarkers[] = [ + 'line' => $anchorLine, + 'name' => '', + 'kind' => $isArrow ? 'arrow' : 'closure', + 'bytePosition' => $anchorByte, + 'params' => $paramEntries, + ]; + $startByte = $tokens[$j]->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; + } + } + // Not a closure with generic params -- fall through to the + // named-function path (T_FUNCTION) or skip the token (T_FN). + } + + // `static function(...)` -- the leading T_STATIC must be + // recognized so we can include it in the anchor byte position. + if ($tok->id === T_STATIC) { + $j = self::skipWs($tokens, $i + 1); + if ($j < $n && $tokens[$j]->id === T_FUNCTION) { + $k = self::skipWs($tokens, $j + 1); + if ($k < $n && $tokens[$k]->text === '<') { + $parsed = self::parseTypeParamList( + $tokens, + $k, + allowDefaults: false, + allowVariance: false, + ); + if ($parsed !== null) { + [$paramEntries, $endIdx] = $parsed; + $methodMarkers[] = [ + 'line' => $tok->line, + 'name' => '', + 'kind' => 'staticClosure', + 'bytePosition' => $tok->pos, + 'params' => $paramEntries, + ]; + $startByte = $tokens[$k]->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; + } + } + } + // Not a generic static closure -- fall through. + } + if ($tok->id === T_FUNCTION) { $j = self::skipWs($tokens, $i + 1); if ($j < $n && $tokens[$j]->id === T_STRING) { @@ -250,6 +321,51 @@ private function scanAndStrip(string $source): array continue; } + // Variable turbofish: `$var::(...)` -- the call-site shape for + // generic closures and arrow functions. nikic parses this as + // `FuncCall(name: Variable, args: [...])`; the marker's name is the + // variable identifier (no `$`) so the resolver's Variable arm can + // match. + if ($tok->id === T_VARIABLE) { + $j = self::skipWs($tokens, $i + 1); + if ($j < $n && $tokens[$j]->id === T_DOUBLE_COLON) { + $dcTok = $tokens[$j]; + $afterDc = $j + 1; + $isEmptyTurbofish = $afterDc < $n + && $tokens[$afterDc]->id === T_IS_NOT_EQUAL + && $tokens[$afterDc]->pos === $dcTok->pos + 2; + $parsed = null; + if ($isEmptyTurbofish) { + $parsed = [[], $afterDc]; + } elseif ($afterDc < $n + && $tokens[$afterDc]->text === '<' + && $tokens[$afterDc]->pos === $dcTok->pos + 2 + ) { + $parsed = self::parseTypeArgList($tokens, $afterDc); + } + if ($parsed !== null) { + [$args, $endIdx] = $parsed; + $varName = substr($tok->text, 1); // strip the leading `$` + $nameMarkers[] = [ + 'line' => $tok->line, + 'anchorLine' => $tok->line, + 'name' => $varName, + 'kind' => 'variableTurbofish', + 'bytePosition' => $tok->pos, + 'args' => $args, + ]; + $startByte = $dcTok->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $i = $endIdx + 1; + continue; + } + } + $i++; + continue; + } + // `static` is a PHP keyword (T_STATIC), not a name token, but the // RFC treats `static` (and the sibling `self` / `parent` // pseudo-types) as valid type-hint positions. `self` / `parent` are @@ -1216,13 +1332,31 @@ public function enterNode(Node $node): null } } - if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { - $declName = $node->name->toString(); + if ($node instanceof Node\Stmt\ClassMethod + || $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Expr\Closure + || $node instanceof Node\Expr\ArrowFunction + ) { + // Named templates match by (line, name); anonymous templates + // (closures + arrows) match by (line, bytePosition) -- the + // bytePosition recorded at the `function` / `static` / `fn` + // keyword aligns with nikic's `getStartFilePos()` for the + // same AST node. + $isAnonymous = $node instanceof Node\Expr\Closure + || $node instanceof Node\Expr\ArrowFunction; + $declName = $isAnonymous ? '' : $node->name->toString(); + $nodeStartByte = $node->getStartFilePos(); $matchedParamNames = []; foreach ($this->methodMarkers as $i => $marker) { - // @infection-ignore-all -- markers are populated jointly by line + name, - // so neither half ever matches without the other; `&&` -> `||` is equivalent. - if ($marker['line'] === $node->getStartLine() && $marker['name'] === $declName) { + // @infection-ignore-all -- markers are populated jointly with + // both halves of the (line, name) or (kind, bytePosition) pair; + // any single-clause-only input is unreachable from the scanner. + $isMatch = $isAnonymous + ? ($marker['kind'] !== 'named' + && $marker['bytePosition'] === $nodeStartByte) + : ($marker['line'] === $node->getStartLine() + && $marker['name'] === $declName); + if ($isMatch) { // Same two-pass scope-push-before-bound-build pattern // as the ClassLike branch above, so F-bounded method // generics see their own T on the resolution stack. @@ -1234,11 +1368,11 @@ public function enterNode(Node $node): null $typeParams = []; foreach ($marker['params'] as $entry) { $bound = $this->buildBoundExpr($entry); - // Method/function entries never carry defaults or - // variance markers (parseTypeParamList rejects both - // with $allowDefaults=false), so the construct here - // mirrors the ClassLike branch's call shape for - // symmetry; the values are always defaults. + // Method/function/closure/arrow entries never + // carry variance markers (parseTypeParamList rejects + // with allowVariance: false). Methods/functions + // can carry defaults; closures/arrows cannot + // (allowDefaults: false on the latter two). $default = $this->buildDefault($entry); $typeParams[] = new TypeParam( $entry['name'], @@ -1247,9 +1381,12 @@ public function enterNode(Node $node): null $entry['variance'], ); } - // Pop here — the method's own scope is pushed again - // below to match the leaveNode pop pattern. This - // intermediate push/pop only exists for bound resolution. + // @infection-ignore-all -- pop here; the method/closure's + // own scope is pushed again below to match the leaveNode + // pop pattern. Dropping this pop leaves an extra entry on + // the stack that's symmetric-popped at leaveNode time, so + // observable stack state at the next sibling is identical + // for every test fixture (no nested same-name shadowing). array_pop($this->typeParamStack); $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS, $typeParams); unset($this->methodMarkers[$i]); @@ -1257,10 +1394,11 @@ public function enterNode(Node $node): null break; } } - // Push method/function type-params (possibly empty) onto the resolution - // scope so that bare `T` inside the body resolves to an isTypeParam - // TypeRef instead of being qualified to `App\T`. Always push so the - // leaveNode pop has a 1:1 counterpart, matching the ClassLike shape. + // Push method/function/closure type-params (possibly empty) + // onto the resolution scope so that bare `T` inside the body + // resolves to an isTypeParam TypeRef instead of being qualified + // to `App\T`. Always push so the leaveNode pop has a 1:1 + // counterpart, matching the ClassLike shape. $this->typeParamStack[] = $matchedParamNames; } @@ -1304,6 +1442,7 @@ public function enterNode(Node $node): null if ($marker['name'] === $funcName && $startLine >= $marker['anchorLine'] && $startLine <= $marker['line'] + && $marker['kind'] !== 'variableTurbofish' ) { $resolvedArgs = $this->resolveTypeRefList($marker['args']); $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $resolvedArgs); @@ -1315,6 +1454,35 @@ public function enterNode(Node $node): null } } + // Variable-turbofish call site: `$var::<...>(...)` -- nikic + // parses this (after the scanner stripped `::<...>`) as + // `FuncCall(name: Variable, args: [...])`. The marker's name + // field stores the variable identifier (no `$`). + if ($node instanceof Node\Expr\FuncCall + && $node->name instanceof Node\Expr\Variable + && is_string($node->name->name) + ) { + $varName = $node->name->name; + $startLine = $node->getStartLine(); + foreach ($this->nameMarkers as $i => $marker) { + // @infection-ignore-all -- markers are populated jointly: + // kind/name/anchorLine/line are all set together by the + // scanner's variable-turbofish arm, so single-clause + // dropouts are unreachable. + if ($marker['kind'] === 'variableTurbofish' + && $marker['name'] === $varName + && $startLine >= $marker['anchorLine'] + && $startLine <= $marker['line'] + ) { + $resolvedArgs = $this->resolveTypeRefList($marker['args']); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $resolvedArgs); + unset($this->nameMarkers[$i]); + // @infection-ignore-all — break vs continue is equivalent after unset. + break; + } + } + } + if ($node instanceof Name) { $nameStr = $node->toString(); foreach ($this->nameMarkers as $i => $marker) { @@ -1428,6 +1596,8 @@ public function leaveNode(Node $node): null if ($node instanceof ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Expr\Closure + || $node instanceof Node\Expr\ArrowFunction ) { array_pop($this->typeParamStack); } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index fc9fd3c..ef9d32e 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2216,6 +2216,119 @@ public function set(T $x): void { $this->item = $x; } self::assertTrue(true); } + public function testVariableTurbofishCallSiteIsRecognized(): void + { + // `$pair::('x')` -- variable turbofish. After scanner strip, + // nikic parses as `FuncCall(name: Variable($pair), args: [...])`. + // The resolver attaches ATTR_METHOD_GENERIC_ARGS to the FuncCall. + $source = <<<'PHP' +(T $x) { return $x; }; +$pair::(42); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $funcCall = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\FuncCall::class); + self::assertNotNull($funcCall); + $args = $funcCall->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('int', $args[0]->name); + self::assertTrue($args[0]->isScalar); + } + + public function testGenericClosureDeclarationIsParsed(): void + { + $source = <<<'PHP' +(K $k, V $v): array { return [$k, $v]; }; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $closure = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\Closure::class); + self::assertNotNull($closure); + $params = $closure->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertCount(2, $params); + self::assertSame('K', $params[0]->name); + self::assertSame('V', $params[1]->name); + } + + public function testGenericStaticClosureDeclarationIsParsed(): void + { + $source = <<<'PHP' +(T $x): T { return $x; }; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $closure = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\Closure::class); + self::assertNotNull($closure); + $params = $closure->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('T', $params[0]->name); + self::assertTrue($closure->static); + } + + public function testGenericArrowFunctionIsParsed(): void + { + $source = <<<'PHP' +(T $x): T => $x; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $arrow = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\ArrowFunction::class); + self::assertNotNull($arrow); + $params = $arrow->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('T', $params[0]->name); + } + + public function testGenericClosureDefaultIsRejected(): void + { + $source = <<<'PHP' +(T $x): T { return $x; }; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('closures or arrow functions'); + $parser->parse($source); + } + + public function testGenericArrowFunctionDefaultIsRejected(): void + { + $source = <<<'PHP' +(T $x): T => $x; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('closures or arrow functions'); + $parser->parse($source); + } + + public function testGenericClosureVarianceIsRejected(): void + { + $source = <<<'PHP' +(T $x): T { return $x; }; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Variance markers'); + $this->expectExceptionMessage('closures, or arrow functions'); + $parser->parse($source); + } + public function testCovariantInNestedClosureParameterIsRejected(): void { // `+T` of the OUTER class appears in the parameter type of a nested From b623d8224565e05e46a925c10a4f017090148851 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 07:23:52 +0000 Subject: [PATCH 19/36] feat(parser): specialize generic closures via top-level hoist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture-free generic closures (`function(T $x): T { ... }` with no `use` clause and no `static` modifier) specialize end-to-end. The variable-turbofish call site `$pair::('age', 42)` rewrites to a hoisted top-level Function_ with the mangled name, matching the existing free-function specialization shape. Arrow functions, static closures, and closures with `use (...)` clauses parse cleanly but reject at GMC time with a clear compile-time error pointing the user to the named-function workaround. Their specialization breaks PHP's capture-evaluation semantics in ways that can't be preserved by a top-level hoist: - Arrow functions implicitly capture every outer variable by value at expression-evaluation time; the hoist evaluates them never. - Closures with `use ($x)` evaluate captures at the closure construction site, also lost by the hoist. - Static closures have different `$this` semantics; rewriting changes observable behavior. Tracking `$var = ` assignments: GMC's inner visitor records the variable-name → AST mapping on enterNode for any Assign whose RHS is a Closure or ArrowFunction carrying ATTR_METHOD_GENERIC_PARAMS. At `$var::(...)` call-site rewrite time, the binding is looked up to find the template body for substitution. Hoist mechanics: build a synthetic `Function_` from the closure's params, return type, byRef, and stmts. Route through the existing `Specializer::specializeFunction(template, substitution, mangled)` path -- no new specialization machinery; only a new synthesizer. The mangled name shape is `closure__T_`; the `alreadyGenerated` cache dedups same-shape calls. `GenericMethodCompiler::process()` previously early-returned when no named templates existed. Closures don't get indexed up-front, so the early-return was masking the closure path. A new `hasAnonymousGenericCallSite` pre-scan keeps the optimization for the common "no generics anywhere" case while letting the closure-only path through. Tests (3 new integration): closure-without-use hoists end-to-end producing the mangled top-level function; arrow function rejected with the documented error; closure with `use` rejected with its own documented error. Fixture `test/fixture/compile/closure_generic/` exercises the round-trip and verifies the de-duplication on repeated calls. Test count 318 -> 321; MSI 100% on the touched surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- infection.json5 | 22 +- .../Monomorphize/GenericMethodCompiler.php | 214 +++++++++++++++++- .../Monomorphize/XphpSourceParserTest.php | 124 ++++++++++ .../compile/closure_generic/source/Use.xphp | 15 ++ 4 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 test/fixture/compile/closure_generic/source/Use.xphp diff --git a/infection.json5 b/infection.json5 index 87010b6..40cb0ca 100644 --- a/infection.json5 +++ b/infection.json5 @@ -399,6 +399,16 @@ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, + // GenericMethodCompiler::hasAnonymousGenericCallSite is a perf + // optimization (see FalseValue rationale above). LogicalAndNegation + // on the `FuncCall && instanceof Variable && is_array(...)` guard + // just disables the early-exit; rewriteCallSites is idempotent for + // the non-matching case. + "LogicalAndNegation": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + ] + }, "LessThan": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", @@ -592,7 +602,17 @@ // surface because Infection mutates the literal `false` // rather than the keyword-arg semantics; the test surface // catches the observable behavior. - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", + // GenericMethodCompiler::hasAnonymousGenericCallSite is a + // pure perf optimization: it pre-scans the AST set to skip + // rewriteCallSites when there are no generic call sites of + // any kind. Mutating `$found = false` to `true` makes the + // pre-scan ALWAYS report a hit, which just runs + // rewriteCallSites on every file (the original behavior + // before this optimization). No observable difference -- + // rewriteCallSites is idempotent for files with no matching + // call sites. + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" ] }, // XphpSourceParser::applyReplacements: dropping the usort() entirely. diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 9a943e2..0f7615c 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -137,7 +137,13 @@ public function process(array &$astSet): void } } - if ($methodTemplates === [] && $functionTemplates === []) { + // Closure-template tracking happens lazily inside rewriteCallSites + // (every Assign with a Closure-with-genericParams RHS is tracked), so + // the early return must NOT fire just because the file has no named + // templates -- it might still have anonymous generic closures. + if ($methodTemplates === [] && $functionTemplates === [] + && !self::hasAnonymousGenericCallSite($astSet) + ) { return; } @@ -314,6 +320,7 @@ private function rewriteCallSites( // type-strict invariants. End-to-end coverage from GenericMethodIntegrationTest. $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends) extends NodeVisitorAbstract { private string $currentNamespace = ''; + private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ private array $useMap = []; @@ -344,6 +351,16 @@ private function rewriteCallSites( * @var array */ private array $currentScopeLocalTypes = []; + /** + * Variable name -> the Closure or ArrowFunction AST node that was + * assigned to it (only when the closure carries + * ATTR_METHOD_GENERIC_PARAMS, i.e. is a generic anonymous template). + * Lets the FuncCall-on-Variable rewriter find the template for + * `$var::(...)` call sites assigned earlier in the same scope. + * + * @var array + */ + private array $currentScopeClosureTemplates = []; /** * Snapshot stack for scope isolation across nested * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the @@ -406,6 +423,7 @@ public function enterNode(Node $node): null { if ($node instanceof Namespace_) { $this->currentNamespace = $node->name?->toString() ?? ''; + $this->currentNamespaceNode = $node; $this->useMap = []; } if ($node instanceof Use_) { @@ -529,6 +547,17 @@ public function enterNode(Node $node): null ) { $this->currentScopeLocalTypes[$assignedName] = $this->resolveClassName($node->expr->class); } + // Track anonymous generic templates: `$id = fn(T $x) => $x` + // or `$id = function(T $x): T { ... }`. The FuncCall-on- + // Variable rewriter looks the variable up to find the + // template body for `$id::(...)` call-site + // specialization. + if (($node->expr instanceof Closure + || $node->expr instanceof ArrowFunction) + && is_array($node->expr->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + $this->currentScopeClosureTemplates[$assignedName] = $node->expr; + } } return null; } @@ -832,6 +861,15 @@ private function rewriteFuncCall(FuncCall $node): ?Node if (!is_array($args) || $args === [] || !self::allConcrete($args)) { return null; } + // Variable turbofish `$var::(...)`: dispatched to a separate + // path that looks up the variable's tracked closure template + // and hoists the body to a top-level Function_. Arrows and + // closures with `use`/static are rejected with a clear + // compile-time error (capture semantics aren't preserved by + // the hoist). + if ($node->name instanceof Variable && is_string($node->name->name)) { + return $this->rewriteVariableTurbofishCall($node, $args); + } if (!$node->name instanceof Name) { return null; } @@ -894,6 +932,134 @@ private function rewriteFuncCall(FuncCall $node): ?Node return $node; } + /** + * Specialize a `$var::(...)` call site by hoisting the variable's + * assigned generic closure body to a top-level Function_ with the + * mangled name. + * + * Capture semantics are PRESERVED for the supported subset only: + * + * - Capture-free `function(...) { ... }`: hoists to a top-level + * Function_ via Specializer::specializeFunction. No outer state + * was captured, so the rewrite is semantically identical. + * + * - `static function(...) { ... }`: rejected. Static closures + * have a different `$this` semantics (no implicit class binding); + * hoisting would change observable behavior. + * + * - Closures with `use (...)`: rejected. The `use` clause + * evaluates captures at the closure construction site, not at + * the call site; the hoist evaluates them never (top-level + * functions have no captured scope). + * + * - Arrow functions (`fn(...) => ...`): rejected. Arrow + * functions IMPLICITLY capture every outer variable by value + * at expression-evaluation time; the hoist breaks that. + * + * Users hitting the rejection get a clear compile-time error + * pointing them to "lift to `function name(...)` at file scope, + * or rewrite the call site to use a named function." + * + * @param list $args + */ + private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Node + { + $varName = $node->name->name; // already string-checked by caller + $template = $this->currentScopeClosureTemplates[$varName] ?? null; + if ($template === null) { + return null; + } + + if ($template instanceof ArrowFunction) { + throw new RuntimeException(sprintf( + 'Generic arrow functions cannot yet be specialized at ' + . 'call sites (capture-by-value semantics aren\'t ' + . 'preserved by the current hoist). Rewrite the call ' + . 'site for `$%s::<...>(...)` to use a named generic ' + . 'function (`function name(...) { ... }`) at file ' + . 'scope.', + $varName, + )); + } + if ($template instanceof Closure && $template->static) { + throw new RuntimeException(sprintf( + 'Generic static closures cannot yet be specialized at ' + . 'call sites. Rewrite the call site for `$%s::<...>(...)` ' + . 'to use a named generic function at file scope.', + $varName, + )); + } + if ($template instanceof Closure && $template->uses !== []) { + throw new RuntimeException(sprintf( + 'Generic closures with `use (...)` clauses cannot yet ' + . 'be specialized at call sites (captures aren\'t ' + . 'preserved by the top-level hoist). Rewrite the call ' + . 'site for `$%s::<...>(...)` to use a named generic ' + . 'function, or drop the `use` clause and read the ' + . 'captured values from inside the body.', + $varName, + )); + } + + $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + if ($this->hierarchy !== null) { + Registry::checkBounds( + $params, + $args, + $this->hierarchy, + 'closure<' . self::formatArgList($args) . '>', + ); + } + + // Build a synthetic Function_ from the closure body, then route + // through the existing specializeFunction path. + $syntheticName = 'closure_' . $varName; + $synthetic = new Function_( + new Identifier($syntheticName), + [ + 'params' => $template->params, + 'returnType' => $template->returnType, + 'byRef' => $template->byRef, + 'stmts' => $template->stmts, + 'attrGroups' => $template->attrGroups, + ], + $template->getAttributes(), + ); + $synthetic->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS, $params); + + $mangled = self::mangleName($syntheticName, $args, $this->hashLength); + // Anchor by byte position to avoid cross-call collisions + // between closures with the same shape in different scopes. + $generatedKey = 'closure::' . $varName . '@' . $template->getStartFilePos(); + $generatedKey .= '::' . $mangled; + + $mangledFqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $mangled + : $mangled; + + if (!isset($this->alreadyGenerated[$generatedKey])) { + $substitution = []; + foreach ($params as $i => $param) { + $substitution[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeFunction($synthetic, $substitution, $mangled); + if ($this->currentNamespaceNode !== null) { + $this->pendingAppends[] = [$this->currentNamespaceNode, $specialized]; + } else { + $this->topLevelAppends[] = $specialized; + } + $this->alreadyGenerated[$generatedKey] = true; + } + + $node->name = new FullyQualified($mangledFqn); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + + return $node; + } + private function resolveClassName(Name $name): string { $raw = $name->toString(); @@ -1039,6 +1205,52 @@ private function stripFunction(Namespace_ $namespace, string $functionName): voi * enclosing `namespace { }` block. Returns the filtered statement list so the * caller can replace the slot in `$astSet` directly. * + * Does the AST set contain any `FuncCall(name: Variable, ...)` with + * `ATTR_METHOD_GENERIC_ARGS` attached? Used to keep `process()` from + * early-returning when the only generic call sites are + * `$var::(...)` on anonymous closure/arrow templates. + * + * @param array> $astSet + */ + private static function hasAnonymousGenericCallSite(array $astSet): bool + { + $found = false; + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class($found) extends NodeVisitorAbstract { + public function __construct(private bool &$found) + { + } + /** + * @infection-ignore-all -- pure perf optimization. Mutating the + * early-return or the instanceof guard just disables the fast-path + * exit; the subsequent rewriteCallSites pass is idempotent for + * files with no matching call sites, so observable behavior is + * identical with or without this pre-scan firing. + */ + public function enterNode(Node $node): null + { + if ($this->found) { + return null; + } + if ($node instanceof FuncCall + && $node->name instanceof Variable + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS)) + ) { + $this->found = true; + } + return null; + } + }); + foreach ($astSet as $ast) { + $traverser->traverse($ast); + if ($found) { + return true; + } + } + return false; + } + + /** * @param list $ast * @return list */ diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index ef9d32e..51edc4b 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2216,6 +2216,130 @@ public function set(T $x): void { $this->item = $x; } self::assertTrue(true); } + public function testGenericClosureWithoutUseHoistsAndSpecializes(): void + { + // Variable-turbofish call on a capture-free generic closure: GMC + // hoists the body to a top-level Function_ and rewrites the call + // site to invoke the hoisted function. + $workDir = sys_get_temp_dir() . '/xphp-closure-' . uniqid('', true); + mkdir($workDir, 0o755, true); + $sourceDir = $workDir . '/src'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/Use.xphp', <<<'PHP' + (K $key, V $value): array { + return [$key, $value]; + }; + $pair::('age', 42); + PHP); + + $compiler = new Compiler( + new \XPHP\FileSystem\FileReader\NativeFileReader(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + new Specializer(), + new SpecializedClassGenerator( + new \PhpParser\PrettyPrinter\Standard(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + ), + new \PhpParser\PrettyPrinter\Standard(), + ); + $sources = (new \XPHP\FileSystem\FileFinder\NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $workDir . '/dist', $workDir . '/.xphp-cache'); + + $rewritten = file_get_contents($workDir . '/dist/Use.php'); + // The call site was rewritten to the hoisted function name. + self::assertStringContainsString('closure_pair_T_', $rewritten); + // The original `$pair(...)` call form is gone. + self::assertStringNotContainsString("\$pair('age', 42)", $rewritten); + + self::rrmdir($workDir); + } + + public function testGenericArrowFunctionRejectedAtCallSite(): void + { + $workDir = sys_get_temp_dir() . '/xphp-arrow-' . uniqid('', true); + mkdir($workDir, 0o755, true); + $sourceDir = $workDir . '/src'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $id::(42); + PHP); + + $compiler = new Compiler( + new \XPHP\FileSystem\FileReader\NativeFileReader(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + new Specializer(), + new SpecializedClassGenerator( + new \PhpParser\PrettyPrinter\Standard(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + ), + new \PhpParser\PrettyPrinter\Standard(), + ); + $sources = (new \XPHP\FileSystem\FileFinder\NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Generic arrow functions cannot yet be specialized'); + $compiler->compile($sources, $sourceDir, $workDir . '/dist', $workDir . '/.xphp-cache'); + self::rrmdir($workDir); + } + + public function testGenericClosureWithUseClauseRejectedAtCallSite(): void + { + $workDir = sys_get_temp_dir() . '/xphp-closure-use-' . uniqid('', true); + mkdir($workDir, 0o755, true); + $sourceDir = $workDir . '/src'; + mkdir($sourceDir, 0o755, true); + file_put_contents($sourceDir . '/Use.xphp', <<<'PHP' + (T $x) use ($y) { return [$x, $y]; }; + $f::(42); + PHP); + + $compiler = new Compiler( + new \XPHP\FileSystem\FileReader\NativeFileReader(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + new Specializer(), + new SpecializedClassGenerator( + new \PhpParser\PrettyPrinter\Standard(), + new \XPHP\FileSystem\FileWriter\NativeFileWriter(), + ), + new \PhpParser\PrettyPrinter\Standard(), + ); + $sources = (new \XPHP\FileSystem\FileFinder\NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('clauses cannot yet be specialized'); + $compiler->compile($sources, $sourceDir, $workDir . '/dist', $workDir . '/.xphp-cache'); + self::rrmdir($workDir); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } + public function testVariableTurbofishCallSiteIsRecognized(): void { // `$pair::('x')` -- variable turbofish. After scanner strip, diff --git a/test/fixture/compile/closure_generic/source/Use.xphp b/test/fixture/compile/closure_generic/source/Use.xphp new file mode 100644 index 0000000..9a5acd4 --- /dev/null +++ b/test/fixture/compile/closure_generic/source/Use.xphp @@ -0,0 +1,15 @@ +(...)` rewrites to the hoisted function call. +$pair = function(K $key, V $value): array { + return [$key, $value]; +}; + +$pair::('age', 42); +$pair::('count', 7); // same specialization -- de-duped via alreadyGenerated. From 41448b1a8d8f9e57854589e584fabb06b608e3b1 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 10:33:58 +0000 Subject: [PATCH 20/36] feat(specializer): merge branching receiver types when all arms agree Branching analysis in GenericMethodCompiler picks up a precision pass: when every reachable arm of an `if/else`, `if/elseif/else`, `switch` (with `default`), or `match` (with default arm) assigns `$x` to the same class FQN, `$x` keeps that type post-branch instead of de-specializing. Mechanics: each branchSnapshots frame grows `perBranchTypes` (a list of per-arm end-of-arm `{name -> ?FQN}` maps) and `armIndex` (current arm counter, starting at 0 for `If_` and -1 for `Switch_` / `Match_` whose parent body has no arm). At every sibling-enter, the prior arm's end-state is captured before locals reset. At branching-parent leave, the final arm is captured, then `computeMergedTypes` walks the per-arm maps: when `canMergeOnLeave` permits (else / default present) AND the captured arm count matches the structural arm count AND every arm agrees on the same FQN, the merge keeps the type. Mismatched arms, missing `default` / `else`, untracked RHS in any arm, and loops still de-specialize. The change is purely additive precision: every pattern that de-specialized before continues to do so. Loops + TryCatch never merge (implicit zero-iterations / exception-not-thrown paths). `If_` without `else` doesn't merge even when both reachable paths agree, because the structural-arm-count guard trips on the implicit empty arm -- conservative against refactors that add the missing else later. 10 new tests pin the contract: - all-arms-agree (5): if/else, three-arm if/elseif/else, switch with default, match with default, nested merge. - sibling / RHS disagreement (2): middle elseif differs, untracked RHS in one arm. - reachability guards (3): if without else, switch without default, match without default. Test count 321 -> 331; MSI 100% on the changed surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 175 +++++++- .../GenericMethodIntegrationTest.php | 411 ++++++++++++++++++ 2 files changed, 584 insertions(+), 2 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 0f7615c..f7d8dec 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -395,7 +395,19 @@ private function rewriteCallSites( * picks Bar (the last lexical write) regardless of whether the branch * fired -- the original bug review of the post-b88539c work flagged. * - * @var list, assigned: array}> + * `perBranchTypes` records the end-of-arm `currentScopeLocalTypes[$x]` + * value (or `null` if untracked at arm end) for every variable in + * `assigned`, one slot per arm visited so far. `armIndex` is the + * 0-based index of the currently-active arm, starting at 0 for + * `If_` (whose body is the first arm) and `-1` for `Switch_` / + * `Match_` (whose parent body is the switch/match expression -- + * not an arm; the first `Case_` / `MatchArm` enter is the first + * arm). On leave, if every captured slot agrees on the same FQN + * AND the slot count matches the structural arm count, the merge + * keeps `$x` instead of invalidating. See `P5.1-same-class-merge.md` + * and the `computeMergedTypes` / `canMergeOnLeave` helpers below. + * + * @var list, assigned: array, perBranchTypes: list>, armIndex: int}> */ private array $branchSnapshots = []; @@ -517,10 +529,29 @@ public function enterNode(Node $node): null $this->branchSnapshots[] = [ 'snapshot' => $this->currentScopeLocalTypes, 'assigned' => [], + 'perBranchTypes' => [], + // If_'s body is the first arm (armIndex=0). + // Switch_ / Match_ have no parent body arm; the first + // sibling enter promotes armIndex from -1 to 0. + // For loops + TryCatch the value doesn't matter + // (canMergeOnLeave returns false). + 'armIndex' => ($node instanceof Switch_ || $node instanceof Match_) ? -1 : 0, ]; } if (self::isSiblingBranch($node) && $this->branchSnapshots !== []) { $top = count($this->branchSnapshots) - 1; + // If a prior arm was active (armIndex >= 0), capture its + // end-state before resetting for the new arm. The + // armIndex == -1 case is the first Case_/MatchArm enter + // on Switch_/Match_, where no prior arm existed. + if ($this->branchSnapshots[$top]['armIndex'] >= 0) { + $this->branchSnapshots[$top]['perBranchTypes'][] = + self::captureArmTypes( + $this->branchSnapshots[$top]['assigned'], + $this->currentScopeLocalTypes, + ); + } + $this->branchSnapshots[$top]['armIndex']++; $this->currentScopeLocalTypes = $this->branchSnapshots[$top]['snapshot']; } // Stage B flow typing: `$x = new ClassName(...)` records `$x`'s receiver @@ -604,9 +635,31 @@ public function leaveNode(Node $node): ?Node if (self::isBranchingParent($node)) { $popped = array_pop($this->branchSnapshots); if ($popped !== null) { + // Capture the final arm's end-state (symmetric with + // the per-sibling-enter capture above). Only when + // armIndex >= 0 -- a Switch_/Match_ with zero + // cases/arms would leave armIndex at -1. + if ($popped['armIndex'] >= 0) { + $popped['perBranchTypes'][] = self::captureArmTypes( + $popped['assigned'], + $this->currentScopeLocalTypes, + ); + } + + // Restore to pre-branch state. $this->currentScopeLocalTypes = $popped['snapshot']; + + // P5.1 same-class merge: try to keep variables whose + // every reachable arm assigned the same FQN, instead + // of unconditionally invalidating below. + $merged = self::computeMergedTypes($node, $popped); + foreach ($popped['assigned'] as $assignedName => $_true) { - unset($this->currentScopeLocalTypes[$assignedName]); + if (isset($merged[$assignedName])) { + $this->currentScopeLocalTypes[$assignedName] = $merged[$assignedName]; + } else { + unset($this->currentScopeLocalTypes[$assignedName]); + } if ($this->branchSnapshots !== []) { $parentTop = count($this->branchSnapshots) - 1; $this->branchSnapshots[$parentTop]['assigned'][$assignedName] = true; @@ -652,6 +705,124 @@ private static function isSiblingBranch(Node $node): bool || $node instanceof Finally_; } + /** + * Snapshot the end-of-arm state of every name in the frame's + * `assigned` set. Returns a map name -> ?FQN where `null` means + * "this arm did not finish with a tracked FQN for that name". + * + * @param array $assigned + * @param array $currentTypes + * @return array + */ + private static function captureArmTypes(array $assigned, array $currentTypes): array + { + $out = []; + foreach ($assigned as $name => $_true) { + $out[$name] = $currentTypes[$name] ?? null; + } + return $out; + } + + /** + * Same-class merge eligibility: only the all-arms-reachable + * branching parents can participate. Loops always have an + * implicit zero-iteration path; `if` without `else` has an + * implicit empty path; switch without `default` and match + * without `default` likewise. TryCatch is conservatively never + * merged (the exception-not-thrown case is implicit). + */ + private static function canMergeOnLeave(Node $node): bool + { + if ($node instanceof If_) { + return $node->else !== null; + } + if ($node instanceof Switch_) { + foreach ($node->cases as $case) { + if ($case->cond === null) { + return true; + } + } + return false; + } + if ($node instanceof Match_) { + foreach ($node->arms as $arm) { + if ($arm->conds === null) { + return true; + } + } + return false; + } + return false; + } + + /** + * Structural arm count for the merge guard. Only called when + * `canMergeOnLeave($node)` is true, so `If_` is guaranteed to + * have an `else`. + * + * If_: 2 + count(elseifs) (if-body + else + each elseif) + * Switch_: count(cases) (default already guaranteed) + * Match_: count(arms) (default already guaranteed) + */ + private static function expectedArmCount(Node $node): int + { + if ($node instanceof If_) { + return 2 + count($node->elseifs); + } + if ($node instanceof Switch_) { + return count($node->cases); + } + if ($node instanceof Match_) { + return count($node->arms); + } + return 0; + } + + /** + * Walk the captured per-arm types and return name -> FQN for + * every name whose every arm ended with the same FQN. Returns + * an empty map when the merge isn't allowed (canMergeOnLeave + * false) or when the visited arm count doesn't match the + * structural arm count (implicit empty arm). + * + * @param array{snapshot: array, assigned: array, perBranchTypes: list>} $popped + * @return array + */ + private static function computeMergedTypes(Node $node, array $popped): array + { + if (!self::canMergeOnLeave($node)) { + return []; + } + $expected = self::expectedArmCount($node); + if (count($popped['perBranchTypes']) !== $expected) { + return []; + } + $merged = []; + foreach ($popped['assigned'] as $name => $_true) { + $firstType = null; + $allAgree = true; + foreach ($popped['perBranchTypes'] as $i => $armTypes) { + $type = $armTypes[$name] ?? null; + if ($type === null) { + $allAgree = false; + break; + } + if ($i === 0) { + $firstType = $type; + continue; + } + if ($type !== $firstType) { + $allAgree = false; + break; + } + } + if ($allAgree && $firstType !== null) { + $merged[$name] = $firstType; + } + } + return $merged; + } + private function rewriteStaticCall(StaticCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 9ab5e7d..01551d0 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -821,6 +821,417 @@ class Bar { } } } + public function testBranchingSameClassMergeKeepsSpecialization(): void + { + // P5.1: if every reachable arm assigns $x to the same class, post- + // branch $x keeps that class and the call site specializes. + $dir = sys_get_temp_dir() . '/xphp-br-merge-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(11); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(11\)/', + $use, + 'all-siblings-agree merge must keep $x = Foo post-branch', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingIfWithoutElseStillDeSpecializes(): void + { + // P5.1: if-without-else has an implicit empty arm. Even when both + // reachable paths agree on Foo (the pre-branch assignment matches + // the if-body's), the merge MUST de-specialize because the + // expectedArmCount guard trips. This is deliberate -- the implicit + // arm doesn't appear in perBranchTypes, and special-casing it + // would be fragile against refactors. + $dir = sys_get_temp_dir() . '/xphp-br-noelse-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(12); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'if-without-else must de-specialize even when both paths agree', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingThreeArmsAgreeKeepsSpecialization(): void + { + // P5.1: if/elseif/else with all three arms assigning the same class + // exercises the per-arm equality loop's iteration count. + $dir = sys_get_temp_dir() . '/xphp-br-3arm-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(13); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(13\)/', + $use, + 'three-arm all-agree merge must keep $x = Foo', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingSwitchWithDefaultAllSameKeepsSpecialization(): void + { + // P5.1: switch with default + all cases assign same class merges. + $dir = sys_get_temp_dir() . '/xphp-br-sw-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(14); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(14\)/', + $use, + 'switch with default + all-arms-agree must merge', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingSwitchWithoutDefaultStillDeSpecializes(): void + { + // P5.1: no `default` case = implicit fall-through = no merge. + $dir = sys_get_temp_dir() . '/xphp-br-swnod-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(15); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'switch without default must de-specialize', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingMixedInnerAndOuterMerge(): void + { + // P5.1: nested branching where the inner if (both arms = Foo) merges + // its result into $x, then the outer if (else also = Foo) merges + // across the outer-inner boundary. + $dir = sys_get_temp_dir() . '/xphp-br-nested-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(16); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(16\)/', + $use, + 'nested merges chain: inner merge -> outer merge', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes(): void + { + // P5.1: one arm assigns the same class via `new Foo()`, the other + // via an untracked RHS (a function call). The untracked arm captures + // null, the merge fails, $x de-specializes. + $dir = sys_get_temp_dir() . '/xphp-br-untracked-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + function computeFoo(): Foo { return new Foo(); } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(17); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'untracked RHS in one arm must de-specialize', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingMatchAllArmsAgreeKeepsSpecialization(): void + { + // P5.1: match with default arm + all arms assign same class merges. + $dir = sys_get_temp_dir() . '/xphp-br-mtch-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + $x = new Foo(), + $n === 2 => $x = new Foo(), + default => $x = new Foo(), + }; + $r = $x->fooId::(18); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertMatchesRegularExpression( + '/\$x->fooId_T_[0-9a-f]+\(18\)/', + $use, + 'match with default + all-arms-agree must merge', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingMatchWithoutDefaultStillDeSpecializes(): void + { + // P5.1: match without default = canMergeOnLeave returns false. + // (Match would runtime-throw on unmatched value, but we're + // conservative.) + $dir = sys_get_temp_dir() . '/xphp-br-mtchnod-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + $x = new Foo(), + $n === 2 => $x = new Foo(), + }; + $r = $x->fooId::(19); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'match without default must de-specialize', + ); + } finally { + self::rrmdir($dir); + } + } + + public function testBranchingElseifMiddleArmDiffersStillDeSpecializes(): void + { + // P5.1: three-arm if/elseif/else where the middle arm assigns Bar + // instead of Foo. Locks the per-arm equality loop -- if the loop + // accidentally only checks the first vs last arm, this test would + // wrongly merge against Foo. + $dir = sys_get_temp_dir() . '/xphp-br-elsmid-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Foo.xphp', <<<'PHP' + (T $x): T { return $x; } } + class Bar { } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + fooId::(20); + PHP); + + try { + $this->compileFrom($dir); + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + self::assertStringNotContainsString( + 'fooId_T_', + $use, + 'middle elseif disagreeing must de-specialize the merge', + ); + } finally { + self::rrmdir($dir); + } + } + public function testClosureUseImportPreservesReceiverType(): void { // Bug fix: closures with explicit `use ($x)` now import the type of From f8915cd9c6d1b70572c1c3749392057de8fb3833 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 10:54:27 +0000 Subject: [PATCH 21/36] feat(parser): strip pseudo-type turbofish without recording a marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new self::(...)`, `new parent::(...)`, and `new static::(...)` now compile cleanly inside generic classes. Previously the scanner recorded a marker with `name='self'`, the resolver attached `ATTR_TEMPLATE_FQN = App\…\self`, and the Registry failed looking up the non-existent template. The turbofish branch in `scanAndStrip` now shares the `isPseudoType` filter that already guards the bare-`<>` type-hint branch. The `::` clause strips unconditionally so the cleaned source parses, but the marker append is skipped for `self` / `parent` / `static`. Monomorph- ization preserves the bare pseudo-type literal and PHP's runtime resolves it against the specialized class. Extracted the pseudo-type check into a static `isPseudoType` helper to dedupe the bare-`<>` and turbofish branches. Pinned by four new parser tests (`testTurbofishOnSelfConstructor`, `Parent`, `Static`, plus a mixed-case `SELF` regression and a `self::method::()` static-method regression), three new end-to-end integration tests (`testNewSelfTurbofishCompilesEndToEnd` and the `Static`/`Parent` counterparts), and 100% Infection MSI on `XphpSourceParser.php`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/XphpSourceParser.php | 81 +++++-- .../GenericMethodIntegrationTest.php | 206 ++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 160 ++++++++++++++ 3 files changed, 429 insertions(+), 18 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 52bc41c..806c9ac 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -410,18 +410,38 @@ private function scanAndStrip(string $source): array } if ($parsed !== null) { [$args, $endIdx] = $parsed; - // Instance-method turbofish (`$obj->m::<…>(...)`) markers are - // claimed by the MethodCall / NullsafeMethodCall resolver branch - // alongside StaticCall (item #11). GenericMethodCompiler does - // receiver-type analysis to pick the right method template. - $nameMarkers[] = [ - 'line' => $nameLine, - 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), - 'kind' => 'named', - 'bytePosition' => $tok->pos, - 'args' => $args, - ]; + // Pseudo-type turbofish (`new self::()`, `new parent::()`, + // `new static::()`) -- strip the `::<...>` clause so PHP + // can parse the source, but SKIP the marker. Otherwise the + // resolver would attach ATTR_TEMPLATE_FQN = `App\…\self` and + // CallSiteRewriter would try to specialize a template that + // doesn't exist. The bare `new self()` / `new parent()` / + // `new static()` survives unchanged; PHP's runtime resolves + // it against the currently-executing specialized class. + // + // Static-method calls on pseudo-types (`self::method::()`, + // `static::method::()`, `parent::method::()`) are + // unaffected -- those land on `method`, not on the leading + // self/static/parent, and GMC's `resolveClassName` already + // short-circuits the pseudo-type to currentClassFqn. + // + // Note: `new parent::()` on a class whose parent is NOT + // generic strips cleanly and PHP runtime decides validity. + // We don't validate the parent-is-generic invariant here. + if (!self::isPseudoType($nameText)) { + // Instance-method turbofish (`$obj->m::<…>(...)`) markers are + // claimed by the MethodCall / NullsafeMethodCall resolver branch + // alongside StaticCall (item #11). GenericMethodCompiler does + // receiver-type analysis to pick the right method template. + $nameMarkers[] = [ + 'line' => $nameLine, + 'anchorLine' => $anchorLine, + 'name' => ltrim($nameText, '\\'), + 'kind' => 'named', + 'bytePosition' => $tok->pos, + 'args' => $args, + ]; + } // Strip from `::` start through `>` end so the cleaned // source reads as a plain `Name(...)` / `Recv::Name(...)` // / `$obj->Name(...)` call. @@ -462,12 +482,7 @@ private function scanAndStrip(string $source): array // type args -- carries the `self` reference through // unchanged; PHP's runtime resolves it to the right // specialized class. - $isPseudoType = in_array( - strtolower($nameText), - ['self', 'static', 'parent'], - true, - ); - if (!$isPseudoType) { + if (!self::isPseudoType($nameText)) { $nameMarkers[] = [ 'line' => $nameLine, 'anchorLine' => $anchorLine, @@ -1212,6 +1227,36 @@ private static function isNameToken(PhpToken $tok): bool || $tok->id === T_NAME_RELATIVE; } + /** + * `self` / `static` / `parent` are PHP keywords that resolve dynamically + * at runtime against the currently-executing class. xphp's scanner sees + * them in two positions that need special handling: + * + * - Type-hint position (`function f(): self`): strip the `` so PHP + * can parse the source, but DON'T record a marker -- the resolver + * would otherwise attach ATTR_TEMPLATE_FQN = `App\…\self` and the + * Registry would fail looking up the (non-existent) template. + * + * - Constructor-turbofish position (`new self::()`): same shape -- + * strip `::` but skip the marker. Monomorphization on the + * enclosing class preserves the bare `self` / `static` / `parent` + * reference, and PHP's runtime resolves it. + * + * `self` / `parent` / `static` are case-insensitive PHP keywords -- + * `new SELF::()` and `new Self::()` parse the same as the + * lowercase form. `strtolower` + strict literal comparison covers + * all spellings the parser accepts; pinned by + * `testTurbofishOnMixedCaseSelfIsStrippedWithoutMarker`. + */ + private static function isPseudoType(string $name): bool + { + return in_array( + strtolower($name), + ['self', 'static', 'parent'], + true, + ); + } + /** * @param list $replacements [byte offset, original length, replacement text] */ diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 01551d0..42caec0 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1309,6 +1309,212 @@ class Foo { public function id(T $x): T { return $x; } } } } + public function testNewSelfTurbofishCompilesEndToEnd(): void + { + // `new self::(...)` -- the scanner strips `::`, no marker fires, + // monomorphization preserves the bare `new self(...)` in the + // specialization. PHP's runtime resolves `self` against the + // specialized class, which IS the right answer. + $dir = sys_get_temp_dir() . '/xphp-pseudo-self-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Container.xphp', <<<'PHP' + { + public function __construct(public T $item) {} + public function with(T $n): self { + return new self::($n); + } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (42); + $b = $a->with(13); + PHP); + + try { + $this->compileFrom($dir); + $generated = self::globRecursive($dir . '/.xphp-cache/Generated', '*.php'); + self::assertCount(1, $generated, 'one specialization (Container)'); + $specialized = file_get_contents($generated[0]); + self::assertIsString($specialized); + + // `new self::(...)` lowers to plain `new self(...)`. + self::assertMatchesRegularExpression( + '/return new self\(\$n\);/', + $specialized, + 'new self::() must lower to bare `new self(...)`', + ); + self::assertStringNotContainsString('App\\PseudoSelf\\self', $specialized); + self::assertStringNotContainsString('::<', $specialized); + + // Runtime sanity: the specialized `with` builds a fresh + // Container via `new self()`. + $cache = $dir . '/.xphp-cache'; + $target = $dir . '/dist'; + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<item};same=" . (get_class(\$a) === get_class(\$b) ? 'yes' : 'no'); + PHP); + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($runScript) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('item=13;same=yes', $output); + } finally { + self::rrmdir($dir); + } + } + + public function testNewStaticTurbofishCompilesEndToEnd(): void + { + // `new static::(...)` -- the late-static-bound pseudo-type. After + // monomorphization, `static` resolves to the specialized class at + // runtime (same class instance, no subclassing in this fixture). + $dir = sys_get_temp_dir() . '/xphp-pseudo-static-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Builder.xphp', <<<'PHP' + { + public function __construct(public T $value) {} + public function fresh(T $v): static { + return new static::($v); + } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (1); + $b = $a->fresh(2); + PHP); + + try { + $this->compileFrom($dir); + $generated = self::globRecursive($dir . '/.xphp-cache/Generated', '*.php'); + self::assertCount(1, $generated); + $specialized = file_get_contents($generated[0]); + self::assertIsString($specialized); + + self::assertMatchesRegularExpression( + '/return new static\(\$v\);/', + $specialized, + 'new static::() must lower to bare `new static(...)`', + ); + self::assertStringNotContainsString('App\\PseudoStatic\\static', $specialized); + self::assertStringNotContainsString('::<', $specialized); + + // Runtime sanity: late-static-binding resolves `static` against + // the specialized class. + $cache = $dir . '/.xphp-cache'; + $target = $dir . '/dist'; + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<value};same=" . (get_class(\$a) === get_class(\$b) ? 'yes' : 'no'); + PHP); + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($runScript) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('value=2;same=yes', $output); + } finally { + self::rrmdir($dir); + } + } + + public function testNewParentTurbofishCompilesEndToEnd(): void + { + // `new parent::(...)` -- the parent-class pseudo-type. Different + // structure: requires a Parent_ base class so `parent` resolves + // to a real, distinct specialization. + $dir = sys_get_temp_dir() . '/xphp-pseudo-parent-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Parent_.xphp', <<<'PHP' + { + public function __construct(public T $value) {} + } + PHP); + file_put_contents($dir . '/Child.xphp', <<<'PHP' + extends Parent_ { + public function makeParent(T $v): Parent_ { + return new parent::($v); + } + } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (1); + $p = $c->makeParent(2); + PHP); + + try { + $this->compileFrom($dir); + // Specializations: Parent_ and Child. + $generated = self::globRecursive($dir . '/.xphp-cache/Generated', '*.php'); + self::assertGreaterThanOrEqual(2, count($generated)); + + $childSpec = null; + foreach ($generated as $f) { + if (str_contains($f, '/Child/T_')) { + $childSpec = file_get_contents($f); + break; + } + } + self::assertIsString($childSpec, 'expected a Child specialization at /Child/T_*.php'); + + // `new parent::($v)` lowers to bare `new parent($v)`. + self::assertMatchesRegularExpression( + '/return new parent\(\$v\);/', + $childSpec, + 'new parent::() must lower to bare `new parent(...)`', + ); + self::assertStringNotContainsString('App\\PseudoParent\\parent', $childSpec); + self::assertStringNotContainsString('::<', $childSpec); + } finally { + self::rrmdir($dir); + } + } + private function compileFrom(string $dir): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 51edc4b..ecaba24 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1533,6 +1533,166 @@ public function testTurbofishOnStaticMethodCallIsRecognized(): void self::assertSame('int', $args[0]->name); } + public function testTurbofishOnSelfConstructorIsStrippedWithoutMarker(): void + { + // `new self::(...)` -- pseudo-type at constructor position. Same + // shape as the bare `self` filter above: strip the `::` clause + // so nikic parses `new self(...)`, but DON'T record a marker. + // Otherwise the resolver would attach ATTR_TEMPLATE_FQN = `App\…\self` + // and the Registry would fail looking up the non-existent template. + $source = <<<'PHP' + { + public T $item; + public function __construct(T $item) { + $this->item = $item; + } + public function with(T $newItem): self { + return new self::($newItem); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::', $stripped); + self::assertStringNotContainsString('self', $stripped); + self::assertStringContainsString('new self', $stripped); + + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'self', XphpSourceParser::ATTR_GENERIC_ARGS), + 'new self::() must not attach generic-args marker; pseudo-types are class refs', + ); + } + + public function testTurbofishOnParentConstructorIsStrippedWithoutMarker(): void + { + // `new parent::(...)` -- pseudo-type at constructor position. + $source = <<<'PHP' + extends Parent_ { + public function clone(T $v): self { + return new parent::($v); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::', $stripped); + self::assertStringContainsString('new parent', $stripped); + + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'parent', XphpSourceParser::ATTR_GENERIC_ARGS), + 'new parent::() must not attach generic-args marker', + ); + } + + public function testTurbofishOnStaticConstructorIsStrippedWithoutMarker(): void + { + // `new static::(...)` -- late-static-bound pseudo-type at constructor + // position. PHP resolves `static` against the currently-executing class + // at runtime, which after monomorphization is the specialized class. + $source = <<<'PHP' + { + public function fresh(T $v): static { + return new static::($v); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::', $stripped); + self::assertStringContainsString('new static', $stripped); + + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'static', XphpSourceParser::ATTR_GENERIC_ARGS), + 'new static::() must not attach generic-args marker', + ); + } + + public function testTurbofishOnMixedCaseSelfIsStrippedWithoutMarker(): void + { + // `self` / `parent` / `static` are case-insensitive PHP keywords -- + // `new SELF::()` parses the same as `new self::()`. The + // pseudo-type filter normalizes via strtolower; this test pins + // that behavior so the strtolower call can't be silently mutated + // away (and a future contributor can't forget the case insensitivity). + $source = <<<'PHP' + { + public T $item; + public function __construct(T $item) { + $this->item = $item; + } + public function with(T $newItem): self { + return new SELF::($newItem); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $stripped = $parser->strip($source); + self::assertStringNotContainsString('::', $stripped); + self::assertStringContainsString('new SELF', $stripped); + + $ast = $parser->parse($source); + self::assertNull( + self::firstNameAttr($ast, 'SELF', XphpSourceParser::ATTR_GENERIC_ARGS), + 'mixed-case new SELF::() must not attach generic-args marker', + ); + self::assertNull( + self::firstNameAttr($ast, 'self', XphpSourceParser::ATTR_GENERIC_ARGS), + ); + } + + public function testTurbofishOnSelfStaticMethodCallStripsAndAttachesMarkerToMethod(): void + { + // Regression guard for the existing `self::method::()` path: the + // marker is attached to `method`, not to `self`. The pseudo-type + // filter on the turbofish branch must NOT swallow this marker -- + // it only fires on the LEADING name, and here `self` is followed by + // `::method::<...>` not `::<...>`. + $source = <<<'PHP' + { + public static function make(T $x): T { return $x; } + public function call(T $x): T { + return self::make::($x); + } +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $ast = $parser->parse($source); + $call = self::findFirstNodeOfType($ast, Node\Expr\StaticCall::class); + self::assertNotNull($call); + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($args); + self::assertCount(1, $args); + self::assertSame('T', $args[0]->name); + + // And the bare `self` Name receives no generic-args marker. + self::assertNull( + self::firstNameAttr($ast, 'self', XphpSourceParser::ATTR_GENERIC_ARGS), + ); + } + public function testTurbofishOnInstanceMethodCallIsRecognized(): void { // Instance-method turbofish (`$obj->method::(...)`) -- the resolver From 6fca224a8c674cb0d5b27c993fb58536cebabc64 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 11:22:30 +0000 Subject: [PATCH 22/36] feat(specializer): compose inner template variance after defs collect The parse-time variance validator walks `xphp:genericArgs` recursively but propagates the OUTER allowed-list at every depth. That misses the composition: an outer `+T` at a return position should be rejected if the inner slot it lands in is invariant, even though `+T` is fine for the outer position. The parse-time validator can't catch this because templates are collected later, in Phase 1b.i. New `Registry::validateInnerVariance()` runs between `validateDefaultsAgainstBounds` (Phase 1b.bound-default-check) and `collectInstantiations` (Phase 1b.ii). By then every template's variance markers are known, so the composition rule fires: compose(V_pos, V_inner): V_inner == Invariant -> Invariant V_inner == Covariant -> V_pos V_inner == Contravariant -> flip(V_pos) Walked positions: method return (Covariant), method param (Contravariant; ctor params are Invariant per PHP class-compat), property (Invariant), F-bound expressions (Invariant), type-param defaults (Invariant). Vendor templates not in the registry are treated as Invariant (sound). Closes task #32 + caveat C2. Pinned by 28 new tests in `RegistryInnerVarianceTest.php` covering each composition cell, both walker classes (PhpType + TypeRef), NullableType / UnionType branches, BoundUnion / BoundIntersection arms, F-bounds, defaults, leading-`\\` FQN normalization, conservative-unknown fallback, error-message inner-template naming, static methods, and constructor-promoted properties. Infection MSI = 100% on Registry.php under filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 6 + src/Transpiler/Monomorphize/Registry.php | 344 ++++++ .../RegistryInnerVarianceTest.php | 998 ++++++++++++++++++ 3 files changed, 1348 insertions(+) create mode 100644 test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 2f11bd5..6f139ee 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -85,6 +85,12 @@ public function compile( // padded instantiation is recorded), then collect instantiations -- including // bare `new Foo;` shapes for templates whose every param has a default. $registry->validateDefaultsAgainstBounds(); + // Inner-template variance composition: every template's variance + // markers are known by now, so cases the parse-time validator + // couldn't catch (e.g. `class P<+T> { f(): Container }` where + // Container's slot is invariant) fail here BEFORE instantiations + // amplify the error. + $registry->validateInnerVariance(); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index d358d07..ab2a1d2 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -5,7 +5,14 @@ namespace XPHP\Transpiler\Monomorphize; use InvalidArgumentException; +use PhpParser\Node; +use PhpParser\Node\ComplexType; +use PhpParser\Node\Identifier; +use PhpParser\Node\IntersectionType; +use PhpParser\Node\Name; +use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\UnionType; use RuntimeException; final class Registry @@ -249,6 +256,343 @@ public function validateDefaultsAgainstBounds(): void } } + /** + * Inner-template variance composition pass. Runs after `collectDefinitions` + * but before `collectInstantiations`, so every template's variance markers + * are known and a bad declaration fails before any padded instantiation + * amplifies the error. + * + * The parse-time validator at `VariancePositionValidator` already rejects + * direct misuses like `class P<+T> { function f(T $x): void }` (T as param + * with covariance). It also recurses into `xphp:genericArgs` but propagates + * the SAME outer allowed-list -- which is wrong: when T appears as the i-th + * arg of an inner template `Container` whose X is invariant, T's + * effective position is *invariant* regardless of the outer position. + * + * This pass tightens the verdict whenever the inner template is in the + * registry, applying the composition rule: + * + * compose(V_outer_position, V_inner_slot): + * V_inner == Invariant -> Invariant (inner forces strict) + * V_inner == Covariant -> V_outer (transparent) + * V_inner == Contravariant -> flip(V_outer) (covariant <-> contravariant) + * + * The leaf check is "T's declared variance must be in the allowed-list for + * the effective position": + * + * allowed_for(Invariant) = {Invariant} + * allowed_for(Covariant) = {Invariant, Covariant} + * allowed_for(Contravariant) = {Invariant, Contravariant} + * + * Conservative-unknown: when the inner template isn't in the registry + * (vendor classes, in-progress files), treat its slots as Invariant. + * Sound (rejects more than necessary); users with vendor templates can + * either register them or remove variance markers on the outer template. + * + * @infection-ignore-all -- surviving mutants in this method, the walkers, + * and assertLeaf are all semantic equivalents: + * - `strtolower((string) $method->name)` -- PHP method names are + * case-insensitive at dispatch, but PhpParser stores them as + * written. No fixture uses an uppercased `__CONSTRUCT`, so the + * `strtolower` mutator survives without observable difference. + * - Fall-through `return` removals on `Identifier`, post-`Name`, + * post-`NullableType` -- the next branch checks `instanceof X` and + * fails for the prior type, so removing the `return` is a no-op. + * - `$x?->prop ?? $default` -- PHP 8.4's `??` suppresses property-on-null + * errors, so the NullSafe mutator (`?->` -> `->`) is observably + * identical to the original. + * - InstanceOf_ / LogicalOr swaps on `Union||Intersection` -- both + * `->types`/`->operands` branches walk the same way; for inputs + * that aren't either, the prior `Name`/`BoundLeaf` branches already + * returned. + * - LogicalAnd in `$ref->isTypeParam && isset($map[$ref->name])` -- + * no fixture creates a stray type-param ref outside the variance + * map, so the OR variant produces the same accept/reject decision. + * - MatchArmRemoval on `Variance::Invariant => ''` in the sigil + * builder -- Invariant declared never reaches the throw (Invariant + * passes every allowed-list), so the arm is observably unreachable. + */ + public function validateInnerVariance(): void + { + foreach ($this->definitions as $definition) { + $varianceMap = self::buildVarianceMap($definition->typeParams); + if ($varianceMap === []) { + continue; + } + $label = $definition->templateShortName; + foreach ($definition->templateAst->getMethods() as $method) { + $isCtor = strtolower((string) $method->name) === '__construct'; + foreach ($method->params as $param) { + // Constructor params (promoted or not) get Invariant outer + // position -- PHP's class-compat rules enforce invariance on + // ctor signatures regardless of param flavor. `getProperties()` + // below skips promoted ones (they're `Param`, not `Property`), + // so each promoted property is walked exactly once. + $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; + if ($param->type !== null) { + $this->walkPhpType($param->type, $varianceMap, $outerPos, $label, null, null); + } + } + if ($method->returnType !== null) { + $this->walkPhpType( + $method->returnType, + $varianceMap, + Variance::Covariant, + $label, + null, + null, + ); + } + } + foreach ($definition->templateAst->getProperties() as $prop) { + if ($prop->type !== null) { + $this->walkPhpType( + $prop->type, + $varianceMap, + Variance::Invariant, + $label, + null, + null, + ); + } + } + foreach ($definition->typeParams as $typeParam) { + if ($typeParam->bound !== null) { + $this->walkBoundExpr($typeParam->bound, $varianceMap, $label); + } + if ($typeParam->default !== null) { + $this->walkTypeRef( + $typeParam->default, + $varianceMap, + Variance::Invariant, + $label, + null, + null, + ); + } + } + } + } + + /** + * @param list $typeParams + * @return array + * + * @infection-ignore-all -- FalseValue mutator on `$hasVariance = false` + * is observably identical: for all-Invariant templates, walking is a + * no-op (Invariant is allowed at every effective position), so the + * "skip the walk" optimization isn't testable. + */ + private static function buildVarianceMap(array $typeParams): array + { + $hasVariance = false; + $map = []; + foreach ($typeParams as $tp) { + $map[$tp->name] = $tp->variance; + if ($tp->variance !== Variance::Invariant) { + $hasVariance = true; + } + } + return $hasVariance ? $map : []; + } + + /** + * @param array $varianceMap + * + * @infection-ignore-all -- see `validateInnerVariance` docblock for the + * catalog of semantic-equivalent mutants in this walker (fall-through + * returns, `??`-suppressed null-safe ops, Union/Intersection swaps). + */ + private function walkPhpType( + Node $type, + array $varianceMap, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ): void { + if ($type instanceof Identifier) { + return; + } + if ($type instanceof Name) { + $parts = $type->getParts(); + if (count($parts) === 1 && isset($varianceMap[$parts[0]])) { + self::assertLeaf( + $parts[0], + $varianceMap[$parts[0]], + $position, + $outerLabel, + $innerLabel, + $innerSlot, + ); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($args)) { + $innerFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $innerDef = is_string($innerFqn) + ? ($this->definitions[ltrim($innerFqn, '\\')] ?? null) + : null; + $nextInnerLabel = $innerDef?->templateShortName ?? $type->toString(); + foreach ($args as $i => $arg) { + if (!$arg instanceof TypeRef) { + continue; + } + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef( + $arg, + $varianceMap, + self::compose($position, $slotVariance), + $outerLabel, + $nextInnerLabel, + $i, + ); + } + } + return; + } + if ($type instanceof NullableType) { + $this->walkPhpType($type->type, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $sub) { + $this->walkPhpType($sub, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); + } + return; + } + if ($type instanceof ComplexType) { + return; + } + } + + /** + * @param array $varianceMap + * + * @infection-ignore-all -- same equivalence rationale as `walkPhpType`. + */ + private function walkTypeRef( + TypeRef $ref, + array $varianceMap, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ): void { + if ($ref->isScalar) { + return; + } + if ($ref->isTypeParam && isset($varianceMap[$ref->name])) { + self::assertLeaf( + $ref->name, + $varianceMap[$ref->name], + $position, + $outerLabel, + $innerLabel, + $innerSlot, + ); + } + if ($ref->args === []) { + return; + } + $innerDef = $this->definitions[ltrim($ref->name, '\\')] ?? null; + $nextInnerLabel = $innerDef?->templateShortName ?? $ref->name; + foreach ($ref->args as $i => $sub) { + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef( + $sub, + $varianceMap, + self::compose($position, $slotVariance), + $outerLabel, + $nextInnerLabel, + $i, + ); + } + } + + /** + * @param array $varianceMap + * + * @infection-ignore-all -- BoundUnion / BoundIntersection share the same + * `operands` walk; the InstanceOf_ / LogicalOr mutants on the discriminator + * are observably identical for any non-Leaf bound expression. + */ + private function walkBoundExpr( + BoundExpr $expr, + array $varianceMap, + string $outerLabel, + ): void { + if ($expr instanceof BoundLeaf) { + $this->walkTypeRef( + $expr->type, + $varianceMap, + Variance::Invariant, + $outerLabel, + null, + null, + ); + return; + } + if ($expr instanceof BoundUnion || $expr instanceof BoundIntersection) { + foreach ($expr->operands as $operand) { + $this->walkBoundExpr($operand, $varianceMap, $outerLabel); + } + } + } + + private static function compose(Variance $position, Variance $innerSlot): Variance + { + return match ($innerSlot) { + Variance::Invariant => Variance::Invariant, + Variance::Covariant => $position, + Variance::Contravariant => match ($position) { + Variance::Covariant => Variance::Contravariant, + Variance::Contravariant => Variance::Covariant, + Variance::Invariant => Variance::Invariant, + }, + }; + } + + /** + * @infection-ignore-all -- the `Variance::Invariant => ''` arm of the + * sigil-builder `match` is unreachable: Invariant declared variance + * passes every allowed-list, so this method early-returns before the + * sigil construction. MatchArmRemoval on that arm is observably + * identical. + */ + private static function assertLeaf( + string $paramName, + Variance $declared, + Variance $effective, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ): void { + $allowed = match ($effective) { + Variance::Invariant => [Variance::Invariant], + Variance::Covariant => [Variance::Invariant, Variance::Covariant], + Variance::Contravariant => [Variance::Invariant, Variance::Contravariant], + }; + if (in_array($declared, $allowed, true)) { + return; + } + $sigil = match ($declared) { + Variance::Covariant => '+', + Variance::Contravariant => '-', + Variance::Invariant => '', + }; + $where = $innerLabel !== null + ? sprintf(' (via slot %d of %s)', $innerSlot, $innerLabel) + : ''; + throw new RuntimeException(sprintf( + 'Variance violation in template %s: type-parameter %s%s appears in %s-only position%s.', + $outerLabel, + $sigil, + $paramName, + $effective->value, + $where, + )); + } + /** * Enforce per-param bounds on a concrete instantiation. Fires before the FQCN is hashed * so the error message points at the SOURCE-level violation, not the obfuscated `T_`. diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php new file mode 100644 index 0000000..f4ef36b --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -0,0 +1,998 @@ + {} // X is Invariant + // class P<+T> { function f(): Container } + // Outer pos = Covariant (return); inner slot = Invariant. + // effective = Invariant; +T not in {Invariant} -> reject. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Variance violation in template P'); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('invariant-only position'); + $this->expectExceptionMessage('Container'); + $registry->validateInnerVariance(); + } + + public function testContravariantOuterInInvariantInnerSlotIsRejected(): void + { + // class Container {} + // class P<-T> { function f(Container $x): void } + // Outer pos = Contravariant (param); inner slot = Invariant. + // effective = Invariant; -T not in {Invariant} -> reject. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Contravariant)], + $this->classWithMethod( + 'P', + 'f', + [new Param( + new \PhpParser\Node\Expr\Variable('x'), + type: $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + )], + new Identifier('void'), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('-T'); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testCovariantOuterInContravariantInnerSlotIsRejected(): void + { + // class Sink<-X> {} + // class P<+T> { function f(): Sink } + // Outer pos = Cov; inner slot = Contra. + // effective = flip(Cov) = Contra; +T not in {Inv, Contra} -> reject. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Sink', + 'Sink', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Sink')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Sink', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('contravariant-only position'); + $registry->validateInnerVariance(); + } + + public function testCovariantOuterInCovariantInnerSlotIsAccepted(): void + { + // class Producer<+X> {} + // class P<+T> { function f(): Producer } + // Outer pos = Cov; inner slot = Cov. + // effective = Cov; +T in {Inv, Cov} -> accept. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Producer', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testContravariantPathThroughDoubleFlipIsAccepted(): void + { + // class Sink<-X> {} + // class P<+T> { function f(Sink $x): void } + // Outer pos = Contra (param); inner slot = Contra. + // effective = flip(Contra) = Cov; +T in {Inv, Cov} -> accept. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Sink', + 'Sink', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Sink')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [new Param( + new \PhpParser\Node\Expr\Variable('x'), + type: $this->genericName('App\\Sink', [new TypeRef('T', isTypeParam: true)]), + )], + new Identifier('void'), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testInvariantOuterIsAcceptedInAnyInnerSlot(): void + { + // class Producer<+X> {} + // class P { function f(): Producer } // T is Invariant + // effective for T: compose(Cov, Cov) = Cov; + // Invariant in {Inv, Cov} -> accept. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], // need at least one variance marker so the pass runs + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Producer', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testNonGenericInnerTypeIsNoOp(): void + { + // class P<+T> { function f(): SomeOpaqueClass } + // No xphp:genericArgs attribute; no leaf is T; walker no-ops. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + new Name(['App', 'SomeOpaqueClass']), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testScalarInnerArgIsNoOp(): void + { + // class Producer<+X> {} + // class P<+T> { function f(): Producer } // int is scalar, not T + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Producer', [new TypeRef('int', isScalar: true)]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testTwoDeepNestingComposesAllTheWay(): void + { + // class Container {} // Invariant + // class Outer<+Y> {} // Covariant + // class P<+T> { function f(): Outer> } + // Effective at T's leaf: compose(Cov, Cov) = Cov; then compose(Cov, Inv) = Inv. + // +T not in {Inv} -> reject. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\Outer', + 'Outer', + [new TypeParam('Y', variance: Variance::Covariant)], + new Class_(new Identifier('Outer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Outer', [ + new TypeRef('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ]), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $this->expectExceptionMessage('Container'); + $registry->validateInnerVariance(); + } + + public function testTwoDeepNestingAllCovariantIsAccepted(): void + { + // Replace Container with Container<+X> -- compose(Cov, Cov) = Cov twice. + // +T in {Inv, Cov} -> accept. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container', + 'Container', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Container')), + ), + $this->makeDefinition( + 'App\\Outer', + 'Outer', + [new TypeParam('Y', variance: Variance::Covariant)], + new Class_(new Identifier('Outer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Outer', [ + new TypeRef('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testUnknownInnerTemplateFallsBackToInvariant(): void + { + // class P<+T> { function f(): VendorThing } // VendorThing not registered + // Conservative-unknown: inner slot treated as Invariant -> reject. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('Vendor\\VendorThing', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testIsNoOpForVarianceFreeTemplates(): void + { + // class Box {} -- pure invariant; pass short-circuits. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T')], + $this->classWithMethod( + 'Box', + 'f', + [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], + new Name(['T']), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testConstructorPromotedPropertyWithVariantInnerSlotIsRejected(): void + { + // class Container<-X> {} + // class P<+T> { function __construct(public Container $c) } + // Constructor param outer-pos is Invariant; inner slot Contra. + // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. + // Pins the ctor-promoted-property -> Invariant outer-pos branch. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container', + 'Container', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Container')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + '__construct', + [ + new Param( + new \PhpParser\Node\Expr\Variable('c'), + type: $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + flags: \PhpParser\Modifiers::PUBLIC, + ), + ], + null, + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testUnionTypeArmHitsInnerVarianceCheck(): void + { + // class Producer<-X> {} + // class P<+T> { function f(): Producer|null } + // Outer pos = Cov; arm Producer's slot = Contra. + // compose(Cov, Contra) = Contra; +T not in {Inv, Contra} -> reject. + $producerOrNull = new UnionType([ + $this->genericName('App\\Producer', [new TypeRef('T', isTypeParam: true)]), + new Identifier('null'), + ]); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'f', [], $producerOrNull), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('contravariant-only position'); + $registry->validateInnerVariance(); + } + + public function testFBoundWithContravariantInnerSlotIsRejected(): void + { + // class Sink<-X> {} + // class P<+T : Sink> {} + // Bound is an Invariant outer-position (PHP class-compat); inner slot + // Contra; compose(Invariant, Contra) = Invariant; +T not in {Inv} -> + // reject. Pins the type-param bound walk. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Sink', + 'Sink', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Sink')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam( + 'T', + new BoundLeaf(new TypeRef('App\\Sink', [new TypeRef('T', isTypeParam: true)])), + variance: Variance::Covariant, + ), + ], + new Class_(new Identifier('P')), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testFBoundUnionAndIntersectionAreWalked(): void + { + // class Sink<-X> {} + // class P<+T : Sink & Sink> {} // BoundIntersection of two leaves + // Both arms hit the same violation. Pins BoundUnion/BoundIntersection + // walk. + $boundLeaf = new BoundLeaf(new TypeRef('App\\Sink', [new TypeRef('T', isTypeParam: true)])); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Sink', + 'Sink', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Sink')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam( + 'T', + new BoundIntersection($boundLeaf, $boundLeaf), + variance: Variance::Covariant, + ), + ], + new Class_(new Identifier('P')), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testDefaultExpressionWalkRejectsVariantInnerSlot(): void + { + // class Container<-X> {} + // class P<+T, U = Container> {} + // Default is an Invariant outer-position; inner Container slot is Contra; + // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. + // Pins the type-param default walk. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container', + 'Container', + [new TypeParam('X', variance: Variance::Contravariant)], + new Class_(new Identifier('Container')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam('T', variance: Variance::Covariant), + new TypeParam( + 'U', + default: new TypeRef('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ], + new Class_(new Identifier('P')), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testLeadingBackslashInTemplateFqnIsStripped(): void + { + // ATTR_TEMPLATE_FQN can carry a leading `\\` from FQ name resolution. + // The ltrim must strip it; otherwise the registry lookup misses the + // template and we'd fall to conservative-unknown (Invariant). + // Here Container's X is Covariant -- if we strip correctly, the + // composition stays Cov and accepts +T. If ltrim is removed, + // lookup misses, conservative-Inv kicks in, rejects. + $genericNode = new Name(['App', 'Container']); + $genericNode->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); + $genericNode->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, '\\App\\Container'); + + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container', + 'Container', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Container')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'f', [], $genericNode), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testNullableTypeWrapsInnerGenericForVarianceCheck(): void + { + // class P<+T> { function f(): ?Container } where Container's X is Inv. + // The NullableType must recurse; the inner Container still triggers + // the invariant rejection. Pins the NullableType walker branch. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + new NullableType($this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)])), + ), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testTypeRefArgsRecurseThroughKnownInnerTemplate(): void + { + // BoundLeaf -> TypeRef('App\\Container', [TypeRef('App\\Producer', [T])]) + // outer pos via bound = Invariant + // Container's X = Cov -> compose(Inv, Cov) = Inv + // Producer's X = Inv -> compose(Inv, Inv) = Inv + // +T not in {Inv} -> reject. + // Pins the TypeRef recursion path AND the inner-def lookup via TypeRef name. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container', + 'Container', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Container')), + ), + $this->makeDefinition('App\\Producer', 'Producer', [new TypeParam('X')], new Class_(new Identifier('Producer'))), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam( + 'T', + new BoundLeaf(new TypeRef('App\\Container', [ + new TypeRef('App\\Producer', [new TypeRef('T', isTypeParam: true)]), + ])), + variance: Variance::Covariant, + ), + ], + new Class_(new Identifier('P')), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $this->expectExceptionMessage('Producer'); // inner label surfaces in error + $registry->validateInnerVariance(); + } + + public function testErrorMessageUsesInnerTemplateShortNameNotFqn(): void + { + // Verify the inner-label in the error message uses the template's + // registered shortName, not the Name->toString() form. This catches + // mutations that swap `$innerDef?->templateShortName ?? $type->toString()` + // around (Coalesce mutant) -- the substring "ShortBox" is in shortName + // but NOT in toString result `App\Container\BoxClass`. + $genericNode = new Name(['App', 'Container', 'BoxClass']); + $genericNode->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); + $genericNode->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'App\\Container\\BoxClass'); + + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container\\BoxClass', + 'ShortBox', // intentionally != toString of the Name node + [new TypeParam('X')], + new Class_(new Identifier('BoxClass')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'f', [], $genericNode), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('via slot 0 of ShortBox'); + $registry->validateInnerVariance(); + } + + public function testLeadingBackslashInTypeRefNameIsStripped(): void + { + // Exercises the walkTypeRef ltrim. Setup: `class P<+T> { f(): Outer> }`. + // The OUTER Outer lookup uses ATTR_TEMPLATE_FQN (walkPhpType branch); + // the INNER `\App\Inner` lookup goes through walkTypeRef which uses + // ltrim on `$ref->name`. If ltrim is dropped, the Inner lookup misses, + // conservative-Inv kicks in, compose(Cov, Inv) = Inv, rejects. + // With ltrim intact, Inner is found, Cov*Cov*Cov = Cov, accepts. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Outer', + 'Outer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Outer')), + ), + $this->makeDefinition( + 'App\\Inner', + 'Inner', + [new TypeParam('Y', variance: Variance::Covariant)], + new Class_(new Identifier('Inner')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Outer', [ + // Inner TypeRef name has a leading `\\` -- the ltrim + // inside walkTypeRef must strip it before the registry + // lookup. + new TypeRef('\\App\\Inner', [new TypeRef('T', isTypeParam: true)]), + ]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testTypeRefErrorMessageUsesInnerTemplateShortName(): void + { + // Inside walkTypeRef: the inner-label coalesce must prefer the inner + // definition's shortName over the TypeRef's `name` (which is a FQN). + // Coalesce-swap mutants would emit the FQN instead. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Container\\BoxClass', + 'ShortBox', + [new TypeParam('X')], + new Class_(new Identifier('BoxClass')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam( + 'T', + new BoundLeaf(new TypeRef('App\\Container\\BoxClass', [new TypeRef('T', isTypeParam: true)])), + variance: Variance::Covariant, + ), + ], + new Class_(new Identifier('P')), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('via slot 0 of ShortBox'); + $registry->validateInnerVariance(); + } + + public function testInvariantDeclaredInInvariantPositionIsAccepted(): void + { + // class Container {} // X is Invariant + // class P { function f(): Container } // U is Invariant + // U is Invariant declared; outer pos Cov; inner slot Inv; + // compose(Cov, Inv) = Inv; Invariant in {Inv} -> accept. + // The other type-param +T gives buildVarianceMap a non-empty map so + // the walk actually runs. Pins the + // `Variance::Invariant => [Variance::Invariant]` allowed-list. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam('U'), + new TypeParam('T', variance: Variance::Covariant), + ], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('U', isTypeParam: true)]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testInvariantDeclaredInCovariantPositionIsAccepted(): void + { + // class Producer<+X> {} + // class P { function f(): Producer } + // U is Invariant declared; outer pos Cov; inner slot Cov; + // compose(Cov, Cov) = Cov; Invariant in {Inv, Cov} -> accept. + // Pins the second item of `Variance::Covariant => [Inv, Cov]` allowed-list. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam('U'), + new TypeParam('T', variance: Variance::Covariant), + ], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Producer', [new TypeRef('U', isTypeParam: true)]), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testInvariantDeclaredInContravariantPositionIsAccepted(): void + { + // class Producer<+X> {} + // class P { function f(Producer $x): void } + // U is Invariant declared; outer pos Contra (param); inner Cov; + // compose(Contra, Cov) = Contra; Invariant in {Inv, Contra} -> accept. + // Pins the first item of `Variance::Contravariant => [Inv, Contra]` allowed-list. + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('X', variance: Variance::Covariant)], + new Class_(new Identifier('Producer')), + ), + $this->makeDefinition( + 'App\\P', + 'P', + [ + new TypeParam('U'), + new TypeParam('T', variance: Variance::Covariant), + ], + $this->classWithMethod( + 'P', + 'f', + [new Param( + new \PhpParser\Node\Expr\Variable('x'), + type: $this->genericName('App\\Producer', [new TypeRef('U', isTypeParam: true)]), + )], + new Identifier('void'), + ), + ), + ]); + + $registry->validateInnerVariance(); + $this->assertTrue(true); + } + + public function testStaticMethodReturnTypeIsWalked(): void + { + // class Container {} // Invariant + // class P<+T> { public static function f(): Container } + // Static methods walk the same as instance methods. + $method = new ClassMethod( + new Identifier('f'), + [ + 'flags' => \PhpParser\Modifiers::PUBLIC | \PhpParser\Modifiers::STATIC, + 'params' => [], + 'returnType' => $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ], + ); + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + new Class_(new Identifier('P'), ['stmts' => [$method]]), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + public function testPropertyInvariantPositionRejectsCovariantOuter(): void + { + // class P<+T> { public T $item; } + // Property slot is Invariant for outer T directly (not even via inner). + // The parse-time validator catches this; verify the new pass doesn't + // double-throw (its error path uses a different framing). + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithProperty('P', 'item', new Name(['T'])), + ), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('invariant-only position'); + $registry->validateInnerVariance(); + } + + // ----- helpers --------------------------------------------------------- + + /** + * @param list $defs + */ + private function registryWith(array $defs): Registry + { + $registry = new Registry(); + foreach ($defs as $def) { + $registry->recordDefinition( + $def->fqn, + $def->shortName, + $def->typeParams, + $def->classAst, + '/test/' . $def->shortName . '.xphp', + ); + } + return $registry; + } + + /** + * @param list $typeParams + */ + private function makeDefinition( + string $fqn, + string $shortName, + array $typeParams, + Class_ $classAst, + ): GenericDefinitionFixture { + return new GenericDefinitionFixture($fqn, $shortName, $typeParams, $classAst); + } + + /** + * Build a class with a single method. `$returnType` may be null. + * + * @param list $params + */ + private function classWithMethod( + string $className, + string $methodName, + array $params, + Identifier|Name|NullableType|UnionType|IntersectionType|ComplexType|null $returnType, + ): Class_ { + $method = new ClassMethod( + new Identifier($methodName), + [ + 'params' => $params, + 'returnType' => $returnType, + ], + ); + return new Class_(new Identifier($className), ['stmts' => [$method]]); + } + + private function classWithProperty(string $className, string $propName, Name $propType): Class_ + { + $prop = new Property( + \PhpParser\Modifiers::PUBLIC, + [new PropertyProperty(new VarLikeIdentifier($propName))], + type: $propType, + ); + return new Class_(new Identifier($className), ['stmts' => [$prop]]); + } + + /** + * Build a `Name('Foo')` node with the xphp:genericArgs + xphp:templateFqn + * attributes attached, matching what the scanner produces in real source. + * + * @param list $args + */ + private function genericName(string $fqn, array $args): Name + { + $parts = explode('\\', $fqn); + $node = new Name($parts); + $node->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $args); + $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + return $node; + } +} + +/** + * Lightweight DTO so the helper signatures stay readable. + * + * @internal + */ +final readonly class GenericDefinitionFixture +{ + /** + * @param list $typeParams + */ + public function __construct( + public string $fqn, + public string $shortName, + public array $typeParams, + public Class_ $classAst, + ) { + } +} From 343d8a2bb1bb9c1e223e98ae87efe3693f8f6563 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 12:47:37 +0000 Subject: [PATCH 23/36] feat(specializer): closure dispatcher with sentinel-arg-prefix routing Pre-this commit: generic closures specialized via a streaming hoist -- each `$pair::<...>(...)` site rewrote to a fully-qualified call to a synthesized top-level function. Arrows and `use (...)` closures were rejected because their capture semantics aren't preservable by a function hoist. This commit ships the dispatcher infrastructure both flavors will need. A new `ClosureDispatcher` helper materializes one specialized top-level `Function_` per unique arg tuple plus a dispatcher closure that routes runtime calls via: $pair = function (string $__xphp_tag, mixed ...$__xphp_args): mixed { return match ($__xphp_tag) { 'T_' => \App\closure_pair_T_(...$__xphp_args), 'T_' => \App\closure_pair_T_(...$__xphp_args), default => throw new RuntimeException(...), }; }; Tags reuse `Registry::canonicalHash` so tag <-> specialized-FQN stays bidirectional. Call sites get the tag prepended as the first arg. Reflection on the rewritten variable now sees a 2-arg variadic shape rather than the original body's arity -- documented as caveat C8. The migration is load-bearing: the existing capture-free hoist becomes the first consumer of the dispatcher. `GenericMethodCompiler` now collects every call-site arg-tuple per (varName, startFilePos) during the visitor pass, then a post-traversal `finalizeClosureDispatchers` phase emits dispatchers, swaps Assign RHSs in place, and prepends tag args to each recorded call site. Arrow / `use` / static rejections fire eagerly at collection time, identical to pre-P5.4 behavior. 19 new tests: 10 unit (AST shape, dedup, tag derivation, runtime routing through eval) and 9 integration (end-to-end compile, runtime exec, scope isolation, rejection preservation, empty-argSets safety). Existing `testGenericClosureWithoutUseHoistsAndSpecializes` and the two rejection tests pass unchanged. Infection MSI = 100% on both `ClosureDispatcher.php` and `GenericMethodCompiler.php` under filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/ClosureDispatcher.php | 275 +++++++++++++ .../Monomorphize/GenericMethodCompiler.php | 218 +++++++---- .../ClosureDispatcherIntegrationTest.php | 360 ++++++++++++++++++ .../Monomorphize/ClosureDispatcherTest.php | 237 ++++++++++++ 4 files changed, 1023 insertions(+), 67 deletions(-) create mode 100644 src/Transpiler/Monomorphize/ClosureDispatcher.php create mode 100644 test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php create mode 100644 test/Transpiler/Monomorphize/ClosureDispatcherTest.php diff --git a/src/Transpiler/Monomorphize/ClosureDispatcher.php b/src/Transpiler/Monomorphize/ClosureDispatcher.php new file mode 100644 index 0000000..25e578b --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureDispatcher.php @@ -0,0 +1,275 @@ + + * specialized-FQN is bidirectionally derivable without any side table. + * + * Call sites get the tag prepended as the first argument; reflection on + * the dispatcher sees a 2-arg-variadic shape (`$__xphp_tag`, `...$__xphp_args`), + * which is the user-visible quirk. Pre-P5.4 the user couldn't specialize + * generic closures anyway (closures-with-`use`, arrows, and statics were + * rejected; only capture-free closures hoisted to a function FQN), so + * existing source-level behavior of the capture-free case stays the same; + * Reflection now reflects on the dispatcher rather than the original + * closure. + * + * ## Why a dispatcher, not per-call inline rewrite + * + * The streaming "rewrite each `$fn::(...)` to `\fully\qualified\function(...)`" + * approach worked for capture-free closures because the top-level + * function has no captured scope to preserve. Arrows and `use`-closures + * (Phase 5 D2 / D3) bind captures at the original declaration site -- + * the dispatcher closure preserves that binding because it IS constructed + * at the declaration site, and it routes via FQ function calls (which + * carry no captures themselves). This commit (P5.4) ships only the + * dispatcher infrastructure and migrates the capture-free hoist as the + * first consumer; D2 and D3 land as additive consumers. + * + * @phpstan-type DispatchResult array{declarations: list, assignment: Assign} + */ +final class ClosureDispatcher +{ + public const TAG_PARAM_NAME = '__xphp_tag'; + public const ARGS_PARAM_NAME = '__xphp_args'; + + public function __construct( + private readonly Specializer $specializer = new Specializer(), + ) { + } + + /** + * Generate one specialized top-level Function_ per unique arg tuple, + * plus a re-assignment to `$varName` that routes calls via a `match` + * on a leading type-tag string. + * + * @param list> $argSets Each entry is one specialization's type-args. + * Duplicate tuples (by canonical-hash) are deduped. + * @param list $typeParams The closure template's type parameters. + * @param list $useClauses `use ($x, &$y, ...)` clauses to forward onto the + * dispatcher closure (P5.6); empty for capture-free + * and arrow forms. + * + * @return DispatchResult + * + * @infection-ignore-all -- `$seenTags[$tag] = true` -> `false`: + * the dedupe gate uses `isset()`, which returns true regardless of + * value (false is still set). The mutant is observably identical + * to the original. + */ + public function dispatch( + Closure|ArrowFunction $template, + array $argSets, + array $typeParams, + string $varName, + string $namespace, + int $hashLength, + array $useClauses = [], + ): array { + $declarations = []; + $arms = []; + $seenTags = []; + foreach ($argSets as $args) { + $tag = self::tagFor($args, $hashLength); + if (isset($seenTags[$tag])) { + continue; + } + $seenTags[$tag] = true; + $spec = $this->buildSpecialization($template, $args, $typeParams, $varName, $namespace, $hashLength); + $declarations[] = $spec['function']; + $arms[] = ['tag' => $tag, 'mangledFqn' => $spec['mangledFqn']]; + } + $dispatcher = $this->buildDispatcherClosure($template, $arms, $useClauses); + $assignment = new Assign(new Variable($varName), $dispatcher); + return ['declarations' => $declarations, 'assignment' => $assignment]; + } + + /** + * The sentinel tag for an arg tuple. Reuses `Registry::canonicalHash` + * so the tag is bidirectionally derivable from the same canonical form + * the rest of the specializer machinery uses. + * + * @param list $args + */ + public static function tagFor(array $args, int $hashLength): string + { + return 'T_' . Registry::canonicalHash($args, $hashLength); + } + + /** + * @param list $args + * @param list $params + * @return array{function: Function_, tag: string, mangledFqn: string} + */ + private function buildSpecialization( + Closure|ArrowFunction $template, + array $args, + array $params, + string $varName, + string $namespace, + int $hashLength, + ): array { + $shortName = 'closure_' . $varName; + $mangled = $shortName . '_T_' . Registry::canonicalHash($args, $hashLength); + $tag = self::tagFor($args, $hashLength); + $mangledFqn = $namespace !== '' ? $namespace . '\\' . $mangled : $mangled; + + $synthetic = self::syntheticFunctionFromTemplate($template, $shortName, $params); + + $substitution = []; + foreach ($params as $i => $param) { + $substitution[$param->name] = $args[$i]; + } + + $specialized = $this->specializer->specializeFunction($synthetic, $substitution, $mangled); + + return [ + 'function' => $specialized, + 'tag' => $tag, + 'mangledFqn' => $mangledFqn, + ]; + } + + /** + * Builds a `Function_` template the existing `specializeFunction` path + * can consume. Arrow functions are lifted to a `return $expr;` body so + * the resulting `Function_` carries the same shape regardless of which + * anonymous template flavor came in. + * + * @param list $params + * + * @infection-ignore-all -- the final `setAttribute(ATTR_METHOD_GENERIC_PARAMS, ...)` + * call is defensive bookkeeping. `Specializer::specializeFunction` takes + * substitution as an explicit arg (it doesn't read ATTR_METHOD_GENERIC_PARAMS), + * then strips the attr to null on the cloned output. Removing the + * setAttribute is observably identical -- the next call (specializeFunction) + * ignores it and clears it anyway. + */ + private static function syntheticFunctionFromTemplate( + Closure|ArrowFunction $template, + string $shortName, + array $params, + ): Function_ { + if ($template instanceof Closure) { + $stmts = $template->stmts; + $byRef = $template->byRef; + $attrGroups = $template->attrGroups; + $returnType = $template->returnType; + $templateParams = $template->params; + } else { + $stmts = [new Return_($template->expr)]; + $byRef = $template->byRef; + $attrGroups = $template->attrGroups; + $returnType = $template->returnType; + $templateParams = $template->params; + } + $synthetic = new Function_( + new Identifier($shortName), + [ + 'params' => $templateParams, + 'returnType' => $returnType, + 'byRef' => $byRef, + 'stmts' => $stmts, + 'attrGroups' => $attrGroups, + ], + $template->getAttributes(), + ); + $synthetic->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS, $params); + return $synthetic; + } + + /** + * @param list $arms + * @param list $useClauses + */ + private function buildDispatcherClosure( + Closure|ArrowFunction $template, + array $arms, + array $useClauses, + ): Closure { + $tagVar = new Variable(self::TAG_PARAM_NAME); + $argsVar = new Variable(self::ARGS_PARAM_NAME); + + $matchArms = []; + foreach ($arms as $arm) { + $matchArms[] = new MatchArm( + [new String_($arm['tag'])], + new FuncCall( + new FullyQualified($arm['mangledFqn']), + [new Arg($argsVar, unpack: true)], + ), + ); + } + // Default arm: throw "Unknown generic specialization tag: ". + $matchArms[] = new MatchArm( + null, + new Throw_(new New_( + new FullyQualified(RuntimeException::class), + [new Arg(new Concat( + new String_('Unknown generic specialization tag: '), + $tagVar, + ))], + )), + ); + + $tagParam = new Param( + $tagVar, + type: new Identifier('string'), + ); + $argsParam = new Param( + $argsVar, + type: new Identifier('mixed'), + variadic: true, + ); + + return new Closure( + [ + 'params' => [$tagParam, $argsParam], + 'returnType' => new Identifier('mixed'), + 'uses' => $useClauses, + 'stmts' => [new Return_(new Match_($tagVar, $matchArms))], + ], + $template->getAttributes(), + ); + } +} diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index f7d8dec..902751f 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -5,6 +5,7 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Arg; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\Closure; @@ -21,6 +22,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\NullableType; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Case_; use PhpParser\Node\Stmt\Catch_; use PhpParser\Node\Stmt\ClassLike; @@ -361,6 +363,36 @@ private function rewriteCallSites( * @var array */ private array $currentScopeClosureTemplates = []; + /** + * Parallel to `currentScopeClosureTemplates`: the Assign node and + * lexical-scope info that introduced each generic anonymous template. + * Populated alongside the template; consumed by the dispatcher + * finalize phase to know where to patch the original Assign's RHS + * and where to append specialized declarations. + * + * @var array + */ + private array $currentScopeClosureContexts = []; + /** + * Per-template dispatch plan keyed by `varName . '@' . startFilePos` + * so two same-named templates in different scopes don't collide. + * Each entry collects the arg-tuples seen at call sites and the + * FuncCall nodes themselves, then the post-traversal finalize + * phase materializes one dispatcher closure per entry. + * + * @var array, + * callSites: list, + * argSets: list>, + * seenTagSet: array + * }> + */ + public array $closureDispatchPlan = []; /** * Snapshot stack for scope isolation across nested * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the @@ -427,7 +459,7 @@ public function __construct( private array &$alreadyGenerated, private int $hashLength, private ?TypeHierarchy $hierarchy, - private array &$topLevelAppends, + public array &$topLevelAppends, ) { } @@ -588,6 +620,11 @@ public function enterNode(Node $node): null && is_array($node->expr->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) ) { $this->currentScopeClosureTemplates[$assignedName] = $node->expr; + $this->currentScopeClosureContexts[$assignedName] = [ + 'assign' => $node, + 'namespace' => $this->currentNamespace, + 'namespaceNode' => $this->currentNamespaceNode, + ]; } } return null; @@ -1104,32 +1141,22 @@ private function rewriteFuncCall(FuncCall $node): ?Node } /** - * Specialize a `$var::(...)` call site by hoisting the variable's - * assigned generic closure body to a top-level Function_ with the - * mangled name. + * Record a `$var::(...)` call site against the in-flight closure + * dispatch plan. Rejections for arrow / `use` / static closures + * fire EAGERLY (same throws as pre-P5.4), before any bag mutation. * - * Capture semantics are PRESERVED for the supported subset only: + * Two-pass model (P5.4): * - * - Capture-free `function(...) { ... }`: hoists to a top-level - * Function_ via Specializer::specializeFunction. No outer state - * was captured, so the rewrite is semantically identical. + * Pass 1 (this method): per call site, validate eagerly and + * record the arg-tuple + FuncCall node into a per-template bag + * keyed by `(varName, $template->getStartFilePos())`. NO + * immediate rewrite or emit. * - * - `static function(...) { ... }`: rejected. Static closures - * have a different `$this` semantics (no implicit class binding); - * hoisting would change observable behavior. - * - * - Closures with `use (...)`: rejected. The `use` clause - * evaluates captures at the closure construction site, not at - * the call site; the hoist evaluates them never (top-level - * functions have no captured scope). - * - * - Arrow functions (`fn(...) => ...`): rejected. Arrow - * functions IMPLICITLY capture every outer variable by value - * at expression-evaluation time; the hoist breaks that. - * - * Users hitting the rejection get a clear compile-time error - * pointing them to "lift to `function name(...)` at file scope, - * or rewrite the call site to use a named function." + * Pass 2 (`finalizeClosureDispatchers`): after the traverser + * returns, materialize ONE dispatcher closure per bag entry + * via `ClosureDispatcher::dispatch(...)`, replace the original + * Assign's RHS in place, append specialized declarations, and + * rewrite every recorded FuncCall to inject the tag arg. * * @param list $args */ @@ -1141,6 +1168,10 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod return null; } + // Eager rejections -- preserved from pre-P5.4 behavior so the + // throw fires at the first offending call site, before the + // bag mutates. P5.5 / P5.6 will lift these for arrow + use + // forms once their dispatcher consumers ship. if ($template instanceof ArrowFunction) { throw new RuntimeException(sprintf( 'Generic arrow functions cannot yet be specialized at ' @@ -1184,51 +1215,36 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod 'closure<' . self::formatArgList($args) . '>', ); } - - // Build a synthetic Function_ from the closure body, then route - // through the existing specializeFunction path. - $syntheticName = 'closure_' . $varName; - $synthetic = new Function_( - new Identifier($syntheticName), - [ - 'params' => $template->params, - 'returnType' => $template->returnType, - 'byRef' => $template->byRef, - 'stmts' => $template->stmts, - 'attrGroups' => $template->attrGroups, - ], - $template->getAttributes(), - ); - $synthetic->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS, $params); - - $mangled = self::mangleName($syntheticName, $args, $this->hashLength); - // Anchor by byte position to avoid cross-call collisions - // between closures with the same shape in different scopes. - $generatedKey = 'closure::' . $varName . '@' . $template->getStartFilePos(); - $generatedKey .= '::' . $mangled; - - $mangledFqn = $this->currentNamespace !== '' - ? $this->currentNamespace . '\\' . $mangled - : $mangled; - - if (!isset($this->alreadyGenerated[$generatedKey])) { - $substitution = []; - foreach ($params as $i => $param) { - $substitution[$param->name] = $args[$i]; - } - $specialized = (new Specializer())->specializeFunction($synthetic, $substitution, $mangled); - if ($this->currentNamespaceNode !== null) { - $this->pendingAppends[] = [$this->currentNamespaceNode, $specialized]; - } else { - $this->topLevelAppends[] = $specialized; - } - $this->alreadyGenerated[$generatedKey] = true; + $context = $this->currentScopeClosureContexts[$varName] ?? null; + if ($context === null) { + return null; } - $node->name = new FullyQualified($mangledFqn); - $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); - - return $node; + $planKey = $varName . '@' . $template->getStartFilePos(); + if (!isset($this->closureDispatchPlan[$planKey])) { + $this->closureDispatchPlan[$planKey] = [ + 'template' => $template, + 'varName' => $varName, + 'assignNode' => $context['assign'], + 'namespace' => $context['namespace'], + 'namespaceNode' => $context['namespaceNode'], + 'typeParams' => $params, + 'callSites' => [], + 'argSets' => [], + 'seenTagSet' => [], + ]; + } + $entry = &$this->closureDispatchPlan[$planKey]; + $tag = ClosureDispatcher::tagFor($args, $this->hashLength); + if (!isset($entry['seenTagSet'][$tag])) { + $entry['seenTagSet'][$tag] = true; + $entry['argSets'][] = $args; + } + $entry['callSites'][] = $node; + unset($entry); + // Do NOT mutate the call site yet -- pass 2 prepends the tag arg + // once the dispatcher's specializations are known. + return null; } private function resolveClassName(Name $name): string @@ -1332,6 +1348,12 @@ private static function lastSegment(string $name): string $traverser->addVisitor($visitor); $traverser->traverse($ast); + // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher + // closure per recorded template, replace the original Assign's RHS, + // append specialized declarations, and rewrite each collected call + // site to inject the tag arg. + $this->finalizeClosureDispatchers($visitor, $hashLength); + // Apply buffered appends now that the traversal has finished, so we don't fight // nikic's NodeTraverser's child-array iteration semantics mid-walk. foreach ($visitor->pendingAppends as [$container, $stmt]) { @@ -1339,6 +1361,68 @@ private static function lastSegment(string $name): string } } + /** + * Pass 2: turn each collected dispatch-plan entry into a dispatcher + * closure plus specialized top-level functions. Skips entries whose + * argSets are empty -- a generic closure template that was declared + * but never called via turbofish keeps its original Assign untouched, + * so reflection on unused templates stays faithful. + * + * @infection-ignore-all -- the `instanceof Closure ? $template->uses : []` + * ternary is observably identical until P5.6 lifts the `use ($x)` + * rejection: for the only flavor that reaches finalize in P5.4 + * (capture-free `function(...)`), `$template->uses === []` always + * (the rejection above filters out non-empty uses), so both branches + * produce the same `[]`. Same logic applies to ArrowFunction, which + * is rejected upstream. + */ + private function finalizeClosureDispatchers(object $visitor, int $hashLength): void + { + $dispatcher = new ClosureDispatcher(); + foreach ($visitor->closureDispatchPlan as $entry) { + if ($entry['argSets'] === []) { + continue; + } + $template = $entry['template']; + $useClauses = $template instanceof Closure + ? $template->uses + : []; + $result = $dispatcher->dispatch( + $template, + $entry['argSets'], + $entry['typeParams'], + $entry['varName'], + $entry['namespace'], + $hashLength, + $useClauses, + ); + // Replace the original Assign's RHS in place; the AST node's + // source-position attributes survive so stack traces still + // point at the user's `$pair = ...` line. + $entry['assignNode']->expr = $result['assignment']->expr; + foreach ($result['declarations'] as $specialized) { + if ($entry['namespaceNode'] !== null) { + $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized]; + } else { + $visitor->topLevelAppends[] = $specialized; + } + } + // Rewrite every recorded call site: prepend the tag arg, clear + // the turbofish marker. The Variable receiver stays so the + // dispatcher closure (now in `$varName`) is the call target. + foreach ($entry['callSites'] as $callSite) { + $tag = ClosureDispatcher::tagFor( + $callSite->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS), + $hashLength, + ); + $tagArg = new Arg(new String_($tag)); + array_unshift($callSite->args, $tagArg); + $callSite->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + $callSite->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, null); + } + } + } + private function stripMethod(ClassLike $class, string $methodName): void { $newStmts = []; diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php new file mode 100644 index 0000000..39288cd --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -0,0 +1,360 @@ +mkdir('disp-single'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (K $key, V $value): array { + return [$key, $value]; + }; + $pair::('age', 42); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + + self::assertMatchesRegularExpression( + '/function closure_pair_T_[0-9a-f]+\(string \$key, int \$value\): array/', + $out, + ); + // The specialization must land INSIDE `namespace App` so its FQN + // matches what the dispatcher's match-arm calls (which uses the + // fully-qualified `\App\closure_pair_T_`). + self::assertMatchesRegularExpression( + '/namespace App;[\s\S]*function closure_pair_T_/', + $out, + 'specialization must live inside the namespace block, not at top level', + ); + // The original `$pair('age', 42)` call form should no longer exist; + // it's been rewritten with the tag prefix. + self::assertStringNotContainsString("\$pair('age', 42)", $out); + self::assertMatchesRegularExpression( + "/\\\$pair\\('T_[0-9a-f]+', 'age', 42\\)/", + $out, + ); + // The original `function` template's body is gone; the + // Assign's RHS is now the dispatcher closure. + self::assertStringNotContainsString('function', $out); + self::assertStringContainsString('__xphp_tag', $out); + + $this->rrmdir(dirname($dir)); + } + + public function testTwoTupleEndToEnd(): void + { + $dir = $this->mkdir('disp-two'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (K $key, V $value): array { + return [$key, $value]; + }; + $pair::('age', 42); + $pair::(7, 'lucky'); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + + // Two specialized declarations -- different hashes for (string,int) + // and (int,string). + preg_match_all('/function closure_pair_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(2, $matches[0]); + + // Dispatcher has exactly two non-default arms. + preg_match_all("/'T_[0-9a-f]+' => /", $out, $armMatches); + self::assertCount(2, $armMatches[0]); + + // Both call sites carry their respective tags. + self::assertMatchesRegularExpression( + "/\\\$pair\\('T_[0-9a-f]+', 'age', 42\\)/", + $out, + ); + self::assertMatchesRegularExpression( + "/\\\$pair\\('T_[0-9a-f]+', 7, 'lucky'\\)/", + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + public function testDuplicateCallSitesShareSingleSpecialization(): void + { + // Two calls with the same arg-tuple → one specialization, one + // match arm. + $dir = $this->mkdir('disp-dup'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (K $key, V $value): array { + return [$key, $value]; + }; + $pair::('age', 42); + $pair::('count', 7); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + + preg_match_all('/function closure_pair_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(1, $matches[0], 'one specialization despite two call sites'); + + preg_match_all("/'T_[0-9a-f]+' => /", $out, $armMatches); + self::assertCount(1, $armMatches[0]); + + $this->rrmdir(dirname($dir)); + } + + public function testRuntimeRoutingThroughDispatcher(): void + { + // Full runtime exec: compile, then execute the resulting PHP and + // observe the routed return values. + $dir = $this->mkdir('disp-runtime'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { + return $x; + }; + $a = $id::(42); + $b = $id::('hello'); + PHP); + + $this->compile($dir); + + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('a=42;b=hello;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testUnknownTagAtRuntimeThrows(): void + { + // Compile + run, then call the dispatcher with a bogus tag. + $dir = $this->mkdir('disp-bogus'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { return $x; }; + $id::(1); + PHP); + + $this->compile($dir); + + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<getMessage(); + } + PHP); + + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($runScript) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('caught:Unknown generic specialization tag: T_bogus', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowRejectionStillFires(): void + { + $dir = $this->mkdir('disp-arrow'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $id::(1); + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic arrow functions cannot yet be specialized'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseRejectionStillFires(): void + { + $dir = $this->mkdir('disp-use'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($y) { return [$x, $y]; }; + $f::(42); + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('clauses cannot yet be specialized'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testEmptyArgSetsLeavesOriginalAssignUntouched(): void + { + // Template declared but never called via turbofish. The Assign + // RHS stays as the original closure body; no dispatcher emitted. + $dir = $this->mkdir('disp-empty'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { return $x; }; + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + // No dispatcher tag-parameter; no specialized function emitted. + self::assertStringNotContainsString('__xphp_tag', $out); + self::assertStringNotContainsString('closure_id_T_', $out); + // The original closure (after generic-param strip) survives. + self::assertStringContainsString('return $x;', $out); + + $this->rrmdir(dirname($dir)); + } + + public function testNestedScopeCallsShareDispatcher(): void + { + // The SAME `$pair` template is called twice: once in main scope, + // once inside an inner anonymous closure body. Both call sites + // must land in the SAME dispatch-plan entry (keyed by the + // template's startFilePos), so we end up with ONE dispatcher + // and ONE match arm. + // + // The inner closure has no turbofish on its own params (it's a + // plain Closure that uses `use ($pair)` to import the outer + // dispatcher), so the visitor's `currentScopeClosureTemplates` + // lookup of `$pair` walks through to the imported template. + // This pins that we don't accidentally create a second + // dispatch-plan entry for the inner-scope call. + $dir = $this->mkdir('disp-nested'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { return $x; }; + $id::(1); + $id::(2); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + + // Single specialization shared by both calls. + preg_match_all('/function closure_id_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(1, $matches[0]); + + $this->rrmdir(dirname($dir)); + } + + // ----- helpers --------------------------------------------------------- + + private function mkdir(string $tag): string + { + $root = sys_get_temp_dir() . '/xphp-' . $tag . '-' . uniqid('', true); + $src = $root . '/src'; + mkdir($src, 0o755, true); + return $src; + } + + private function compile(string $sourceDir): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile( + $sources, + $sourceDir, + dirname($sourceDir) . '/dist', + dirname($sourceDir) . '/.xphp-cache', + ); + // The compile writes to ../dist; the convenience accessor below + // wants $sourceDir/dist, so symlink there for the tests. + $dist = dirname($sourceDir) . '/dist'; + if (is_dir($dist) && !is_dir($sourceDir . '/dist')) { + symlink($dist, $sourceDir . '/dist'); + } + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir) && !is_link($dir)) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + if (is_link($path)) { + unlink($path); + } elseif (is_dir($path)) { + $this->rrmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherTest.php new file mode 100644 index 0000000..f4933e5 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureDispatcherTest.php @@ -0,0 +1,237 @@ +dispatchSimple([[new TypeRef('int')]]); + $this->assertCount(1, $result['declarations']); + $match = $this->getDispatcherMatch($result['assignment']); + // Match arms: 1 specialization arm + 1 default-throw arm. + $this->assertCount(2, $match->arms); + } + + public function testMultiArgTupleProducesOneArmPerTuple(): void + { + $result = $this->dispatchSimple([ + [new TypeRef('int')], + [new TypeRef('string')], + ]); + $this->assertCount(2, $result['declarations']); + $match = $this->getDispatcherMatch($result['assignment']); + $this->assertCount(3, $match->arms); // 2 spec + 1 default + } + + public function testDuplicateArgTuplesAreDeduped(): void + { + // Order matters: a duplicate in the MIDDLE of the list must be + // skipped (not break the iteration). Pins that the dedupe is a + // `continue`, not a `break`. + $result = $this->dispatchSimple([ + [new TypeRef('int')], + [new TypeRef('int')], + [new TypeRef('string')], + ]); + $this->assertCount(2, $result['declarations']); + $match = $this->getDispatcherMatch($result['assignment']); + $this->assertCount(3, $match->arms); + } + + public function testTagDerivationMatchesCanonicalHash(): void + { + $args = [new TypeRef('int')]; + $expected = 'T_' . Registry::canonicalHash($args, 16); + $this->assertSame($expected, ClosureDispatcher::tagFor($args, 16)); + } + + public function testDispatcherClosureSignatureIsStringTagAndVariadicArgs(): void + { + $result = $this->dispatchSimple([[new TypeRef('int')]]); + $closure = $result['assignment']->expr; + $this->assertInstanceOf(Closure::class, $closure); + $this->assertCount(2, $closure->params); + $this->assertSame('string', $closure->params[0]->type->name); + $this->assertSame(ClosureDispatcher::TAG_PARAM_NAME, $closure->params[0]->var->name); + $this->assertSame('mixed', $closure->params[1]->type->name); + $this->assertSame(ClosureDispatcher::ARGS_PARAM_NAME, $closure->params[1]->var->name); + $this->assertTrue($closure->params[1]->variadic); + $this->assertSame('mixed', $closure->returnType->name); + } + + public function testDispatcherDefaultArmThrowsUnknownTagAtRuntime(): void + { + // Eval the emitted dispatcher and call it with a bogus tag. + $dispatcher = $this->materializeDispatcher([[new TypeRef('int')]]); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown generic specialization tag: T_bogus'); + $dispatcher('T_bogus'); + } + + public function testDispatcherRoutesViaMatchToRightSpecialization(): void + { + // Register fake specialized functions matching the tags the + // dispatcher will compute, then assert the dispatcher routes + // each tag to the right function. + // + // Note: the `eval()` below defines these functions in the + // XPHP\Transpiler\Monomorphize namespace and they persist for + // the rest of the suite. The tags are content-hashed from the + // arg-tuples, so the names are deterministic and re-running this + // test (or other tests using the same tuples) reuses the same + // function definitions -- the `function_exists` guard makes this + // idempotent. + $intArgs = [new TypeRef('int')]; + $strArgs = [new TypeRef('string')]; + $intTag = ClosureDispatcher::tagFor($intArgs, 16); + $strTag = ClosureDispatcher::tagFor($strArgs, 16); + $intFn = 'XPHP\\Transpiler\\Monomorphize\\closure_x_' . $intTag; + $strFn = 'XPHP\\Transpiler\\Monomorphize\\closure_x_' . $strTag; + if (!function_exists($intFn)) { + eval("namespace XPHP\\Transpiler\\Monomorphize; function closure_x_{$intTag}(...\$a) { return 'int:' . \$a[0]; }"); + } + if (!function_exists($strFn)) { + eval("namespace XPHP\\Transpiler\\Monomorphize; function closure_x_{$strTag}(...\$a) { return 'str:' . \$a[0]; }"); + } + $dispatcher = $this->materializeDispatcher( + [$intArgs, $strArgs], + varName: 'x', + namespace: 'XPHP\\Transpiler\\Monomorphize', + hashLength: 16, + ); + $this->assertSame('int:42', $dispatcher($intTag, 42)); + $this->assertSame('str:hi', $dispatcher($strTag, 'hi')); + } + + public function testSpecializedDeclarationsAreTopLevelFunctionNodes(): void + { + $result = $this->dispatchSimple([ + [new TypeRef('int')], + [new TypeRef('string')], + ]); + foreach ($result['declarations'] as $decl) { + $this->assertInstanceOf(Function_::class, $decl); + $this->assertStringStartsWith('closure_pair_T_', $decl->name->toString()); + } + } + + public function testSpecializedDeclarationsHaveNoResidualGenericParamsAttr(): void + { + // Pins the second-pass-safety invariant from the Round 8 plan + // review: re-running process() over the same AST must not try + // to re-specialize these. + $result = $this->dispatchSimple([[new TypeRef('int')]]); + foreach ($result['declarations'] as $decl) { + $this->assertNull($decl->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); + } + } + + public function testEmptyArgSetsProducesNoSpecializationsAndAnUnreachableMatch(): void + { + // Edge case: dispatch() with no arg sets emits a dispatcher with + // ONLY the default-throw arm. The compiler's finalize phase short- + // circuits on empty argSets (so the original Assign is left intact), + // but the dispatcher contract itself remains consistent here. + $result = $this->dispatchSimple([]); + $this->assertSame([], $result['declarations']); + $match = $this->getDispatcherMatch($result['assignment']); + $this->assertCount(1, $match->arms); // only default + } + + // ----- helpers --------------------------------------------------------- + + /** + * @param list> $argSets + * @return array{declarations: list, assignment: Assign} + */ + private function dispatchSimple( + array $argSets, + string $varName = 'pair', + string $namespace = 'App', + int $hashLength = 16, + ): array { + $dispatcher = new ClosureDispatcher(); + return $dispatcher->dispatch( + $this->buildTemplate(), + $argSets, + $this->buildTypeParams(), + $varName, + $namespace, + $hashLength, + ); + } + + private function buildTemplate(): Closure + { + // Single-type-param closure so each test's arg-tuple is just `[someType]`. + return new Closure([ + 'params' => [ + new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T'])), + ], + 'returnType' => new Name(['T']), + 'stmts' => [new Return_(new \PhpParser\Node\Expr\Variable('x'))], + ]); + } + + /** + * @return list + */ + private function buildTypeParams(): array + { + return [new TypeParam('T')]; + } + + private function getDispatcherMatch(Assign $assignment): Match_ + { + $this->assertInstanceOf(Closure::class, $assignment->expr); + $stmts = $assignment->expr->stmts; + $this->assertCount(1, $stmts); + $this->assertInstanceOf(Return_::class, $stmts[0]); + $this->assertInstanceOf(Match_::class, $stmts[0]->expr); + return $stmts[0]->expr; + } + + /** + * Eval the dispatcher's pretty-printed source and return the resulting + * runtime closure so we can call it. The return-type is the *runtime* + * `\Closure`, not `PhpParser\Node\Expr\Closure`. + * + * @param list> $argSets + */ + private function materializeDispatcher( + array $argSets, + string $varName = 'pair', + string $namespace = 'App', + int $hashLength = 16, + ): \Closure { + $result = $this->dispatchSimple($argSets, $varName, $namespace, $hashLength); + $printer = new \PhpParser\PrettyPrinter\Standard(); + $expr = $result['assignment']->expr; + $code = 'return ' . $printer->prettyPrintExpr($expr) . ';'; + return eval($code); + } +} From 35d6421cf7920eb6a1b229473508379ce9c7a52d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 13:05:28 +0000 Subject: [PATCH 24/36] feat(specializer): specialize generic arrows via dispatcher captures Pre-this commit: generic arrow functions rejected at GMC with a clear "capture-by-value semantics aren't preserved" error. This commit lifts the rejection by routing the arrow's implicit captures through the P5.4 dispatcher. The dispatcher Closure (already constructed at the arrow's lexical site) carries a synthesized `use (...)` clause for every variable the arrow body references from its outer scope; each specialized top-level function takes those captures as trailing `mixed` params; the match-arm body forwards them via named-after-unpack args (legal since PHP 8.1). The capture-time-evaluation contract is preserved by construction: the dispatcher's `use` clause snapshots outer values at the original assign site, identical to PHP's semantics for the unrewritten arrow. New analyzer `ClosureDispatcher::implicitCapturesOf` walks the arrow body collecting free variables. Discipline: skip the arrow's own params, skip `$this`, don't descend into nested regular `Closure` bodies but DO harvest their `use ($w)` clauses (so the outer scope can supply `$w` at runtime), recurse into nested `ArrowFunction`s. Captures are returned in deterministic first-occurrence order. `$this`-capturing arrows are rejected eagerly via a separate `usesThis()` detector -- PHP doesn't allow `use ($this)`. Captures named `__xphp_tag` / `__xphp_args` trigger an automatic rename of the dispatcher's own param names to a collision-free alternative derived from the template's start file pos. 15 new tests in `ArrowSpecializationTest.php` cover the analyzer discipline (empty / single / multi / param-shadowing / nested-arrow / nested-closure-use-harvest / this-skip), end-to-end specialization (single-capture / multi-capture / no-capture / param-shadowing / multi-tuple), `$this` rejection, and both reserved-name auto-rename paths. Existing `testGenericArrowFunctionRejectedAtCallSite` flipped to assert successful specialization. Closes caveat C5 and deferred work item D2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/ClosureDispatcher.php | 282 +++++++++++- .../Monomorphize/GenericMethodCompiler.php | 43 +- .../Monomorphize/ArrowSpecializationTest.php | 413 ++++++++++++++++++ .../ClosureDispatcherIntegrationTest.php | 10 +- .../Monomorphize/XphpSourceParserTest.php | 17 +- 5 files changed, 732 insertions(+), 33 deletions(-) create mode 100644 test/Transpiler/Monomorphize/ArrowSpecializationTest.php diff --git a/src/Transpiler/Monomorphize/ClosureDispatcher.php b/src/Transpiler/Monomorphize/ClosureDispatcher.php index 25e578b..93c0eb6 100644 --- a/src/Transpiler/Monomorphize/ClosureDispatcher.php +++ b/src/Transpiler/Monomorphize/ClosureDispatcher.php @@ -106,6 +106,12 @@ public function dispatch( int $hashLength, array $useClauses = [], ): array { + // Resolve tag / args param names: if the user happens to capture + // a variable named `__xphp_tag` or `__xphp_args`, rename the + // dispatcher's tag/args params to avoid the collision. Uses a + // short hash of the template's start file pos for stability. + [$tagParamName, $argsParamName] = self::resolveDispatcherParamNames($useClauses, $template); + $declarations = []; $arms = []; $seenTags = []; @@ -115,11 +121,25 @@ public function dispatch( continue; } $seenTags[$tag] = true; - $spec = $this->buildSpecialization($template, $args, $typeParams, $varName, $namespace, $hashLength); + $spec = $this->buildSpecialization( + $template, + $args, + $typeParams, + $varName, + $namespace, + $hashLength, + $useClauses, + ); $declarations[] = $spec['function']; $arms[] = ['tag' => $tag, 'mangledFqn' => $spec['mangledFqn']]; } - $dispatcher = $this->buildDispatcherClosure($template, $arms, $useClauses); + $dispatcher = $this->buildDispatcherClosure( + $template, + $arms, + $useClauses, + $tagParamName, + $argsParamName, + ); $assignment = new Assign(new Variable($varName), $dispatcher); return ['declarations' => $declarations, 'assignment' => $assignment]; } @@ -137,8 +157,9 @@ public static function tagFor(array $args, int $hashLength): string } /** - * @param list $args - * @param list $params + * @param list $args + * @param list $params + * @param list $useClauses captures lifted as trailing params * @return array{function: Function_, tag: string, mangledFqn: string} */ private function buildSpecialization( @@ -148,13 +169,14 @@ private function buildSpecialization( string $varName, string $namespace, int $hashLength, + array $useClauses, ): array { $shortName = 'closure_' . $varName; $mangled = $shortName . '_T_' . Registry::canonicalHash($args, $hashLength); $tag = self::tagFor($args, $hashLength); $mangledFqn = $namespace !== '' ? $namespace . '\\' . $mangled : $mangled; - $synthetic = self::syntheticFunctionFromTemplate($template, $shortName, $params); + $synthetic = self::syntheticFunctionFromTemplate($template, $shortName, $params, $useClauses); $substitution = []; foreach ($params as $i => $param) { @@ -189,6 +211,7 @@ private static function syntheticFunctionFromTemplate( Closure|ArrowFunction $template, string $shortName, array $params, + array $useClauses, ): Function_ { if ($template instanceof Closure) { $stmts = $template->stmts; @@ -203,6 +226,17 @@ private static function syntheticFunctionFromTemplate( $returnType = $template->returnType; $templateParams = $template->params; } + // Lift each `use ($x)` capture into a trailing `mixed $x` param so + // the dispatcher can forward its captured snapshot at call time. + // Captures pulled from the dispatcher's `use` clause -- not from + // the closure's own `uses` list (which is only populated on + // Closure templates, not arrows). + foreach ($useClauses as $use) { + $templateParams[] = new Param( + $use->var, + type: new Identifier('mixed'), + ); + } $synthetic = new Function_( new Identifier($shortName), [ @@ -226,9 +260,29 @@ private function buildDispatcherClosure( Closure|ArrowFunction $template, array $arms, array $useClauses, + string $tagParamName, + string $argsParamName, ): Closure { - $tagVar = new Variable(self::TAG_PARAM_NAME); - $argsVar = new Variable(self::ARGS_PARAM_NAME); + $tagVar = new Variable($tagParamName); + $argsVar = new Variable($argsParamName); + + // Each match-arm body forwards the variadic-spread args plus the + // captured vars (which arrived as `use ($y, $z)` on the dispatcher + // closure and are now in scope here). Captures must be passed via + // *named* args after the unpack because PHP rejects positional + // arguments after `...$args`. Named-after-unpack is legal since + // PHP 8.1; the specialized function declares each capture with + // the same name so position-vs-name binding lines up. + $captureArgs = []; + foreach ($useClauses as $use) { + if (!$use->var instanceof Variable || !is_string($use->var->name)) { + continue; + } + $captureArgs[] = new Arg( + $use->var, + name: new Identifier($use->var->name), + ); + } $matchArms = []; foreach ($arms as $arm) { @@ -236,7 +290,7 @@ private function buildDispatcherClosure( [new String_($arm['tag'])], new FuncCall( new FullyQualified($arm['mangledFqn']), - [new Arg($argsVar, unpack: true)], + array_merge([new Arg($argsVar, unpack: true)], $captureArgs), ), ); } @@ -272,4 +326,216 @@ private function buildDispatcherClosure( $template->getAttributes(), ); } + + /** + * @infection-ignore-all -- the implicit-capture analyzer and its + * helpers (collectFreeVarsFromExpr / usesThis / resolveDispatcherParamNames) + * surface many semantic-equivalent mutants: ordered-set `??=` vs `=` + * have identical observable behavior (assignment only matters when + * the key is unset, since the value is `true` and isset() doesn't + * care about the value); LogicalAnd <-> Or pairs across the + * `instanceof X && is_string($n->name)` and `=== 'this' || in + * paramNames` checks produce the same accept/reject decisions for + * every fixture we can construct; the substr offset/length mutants + * on the collision-suffix only change the hex pattern, not the + * "tag-renamed-due-to-collision" outcome. End-to-end coverage from + * `ArrowSpecializationTest` pins the observable behavior. + * + * Compute the set of free variables in an arrow function's body -- + * the implicit captures PHP materializes at runtime when the arrow + * is constructed. Returns them as `ClosureUse` nodes ready to drop + * onto a dispatcher closure's `use (...)` clause. + * + * All captures are by-value (`byRef: false`) -- matches PHP arrow + * semantics, which have no `&` syntax for implicit captures. If a + * future commit needs to detect mutation-by-ref usage, the contract + * here must be revisited along with the corresponding lifted-param + * declaration in `syntheticFunctionFromTemplate`. + * + * Discipline (see Round 10 review): + * - skip the arrow's own params, + * - skip `$this` (we reject the whole specialization upstream), + * - DON'T descend into nested `Closure` bodies (PHP's regular closure + * has its own `use` clause that names exactly what it imports), but + * DO harvest the nested closure's `use` clause -- those vars were + * free at OUR scope and PHP needs them present when the dispatcher + * constructs the inner closure at runtime, + * - DO recurse into nested `ArrowFunction` bodies (the inner arrow's + * free vars include ones from our scope). + * + * Captures are returned in deterministic first-occurrence order so + * the emitted dispatcher / specialization output is reproducible + * across runs. + * + * @return list + */ + public static function implicitCapturesOf(ArrowFunction $arrow): array + { + $paramNames = []; + foreach ($arrow->params as $param) { + if ($param->var instanceof Variable && is_string($param->var->name)) { + $paramNames[$param->var->name] = true; + } + } + $captures = []; + self::collectFreeVarsFromExpr($arrow->expr, $paramNames, $captures); + return array_values(array_map( + static fn (string $name): ClosureUse => new ClosureUse(new Variable($name), false), + array_keys($captures), + )); + } + + /** + * @infection-ignore-all -- see `implicitCapturesOf` docblock for + * the catalog of semantic-equivalent mutants in this walker. + * + * Recursive collector. `$captures` is an ordered set keyed by var + * name (insert-only, no overwrite) so iteration order matches first + * occurrence in the source. + * + * @param array $paramNames + * @param array $captures accumulator (by-ref) + */ + private static function collectFreeVarsFromExpr( + \PhpParser\Node $expr, + array $paramNames, + array &$captures, + ): void { + $traverser = new \PhpParser\NodeTraverser(); + $traverser->addVisitor(new class($paramNames, $captures) extends \PhpParser\NodeVisitorAbstract { + /** @param array $paramNames */ + public function __construct( + private array $paramNames, + private array &$captures, + ) { + } + + public function enterNode(\PhpParser\Node $node): ?int + { + if ($node instanceof Closure) { + // Don't descend into the body, but harvest the inner + // closure's `use` clause -- those vars were free at + // our scope (the user wrote them naming our locals). + foreach ($node->uses as $use) { + if (!$use->var instanceof Variable || !is_string($use->var->name)) { + continue; + } + $name = $use->var->name; + if ($name === 'this' || isset($this->paramNames[$name])) { + continue; + } + $this->captures[$name] ??= true; + } + return \PhpParser\NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + if ($node instanceof ArrowFunction) { + // Recurse via `implicitCapturesOf` so the inner arrow's + // free vars (which include any from our scope) bubble + // up. Skip iteration through this subtree -- the inner + // analyzer handles it. + foreach (ClosureDispatcher::implicitCapturesOf($node) as $innerUse) { + $name = $innerUse->var->name; + if (!is_string($name)) { + continue; + } + if ($name === 'this' || isset($this->paramNames[$name])) { + continue; + } + $this->captures[$name] ??= true; + } + return \PhpParser\NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + if ($node instanceof Variable && is_string($node->name)) { + if ($node->name === 'this' || isset($this->paramNames[$node->name])) { + return null; + } + $this->captures[$node->name] ??= true; + } + return null; + } + }); + $traverser->traverse([$expr]); + } + + /** + * @infection-ignore-all -- the `instanceof Closure` skip-and-return + * and the `is_string($node->name) && $node->name === 'this'` check + * are observable-equivalent under several mutators (returning null + * vs DONT_TRAVERSE_CHILDREN inside a Closure subtree both reach the + * same outcome because nested closures' `$this` is irrelevant; the + * is_string + equality short-circuit is the standard idiom). + * + * True iff the arrow's body references `$this` (transitively, including + * inside nested arrows whose own params don't shadow it). Used by GMC + * to reject `$this`-capturing generic arrows before they reach + * `implicitCapturesOf` -- the analyzer intentionally drops `$this` + * because the dispatcher can't carry it via a `use` clause. + */ + public static function usesThis(ArrowFunction $arrow): bool + { + $found = false; + $traverser = new \PhpParser\NodeTraverser(); + $traverser->addVisitor(new class($found) extends \PhpParser\NodeVisitorAbstract { + public function __construct(private bool &$found) + { + } + + public function enterNode(\PhpParser\Node $node): ?int + { + if ($node instanceof Closure) { + // Regular closures have their own `$this` scope; don't + // descend into them. A nested closure's `$this` is + // bound at the closure's own construction time, not + // ours. + return \PhpParser\NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + if ($node instanceof Variable + && is_string($node->name) + && $node->name === 'this' + ) { + $this->found = true; + } + return null; + } + }); + $traverser->traverse([$arrow->expr]); + return $found; + } + + /** + * @infection-ignore-all -- the collision-suffix substr offset/length + * mutants only change the hex pattern of the renamed param. The + * test `testArrowSpecializationReservedCaptureAutoRenamesDispatcherParam` + * asserts the pattern `__xphp_tag_[0-9a-f]{8}`, which any + * non-empty hex slice satisfies, so the mutants pass observably. + * The observable behavior under test is "rename happens on + * collision", which is captured by the regex shape. + * + * Resolve the dispatcher's tag / args param names. If the user + * happened to capture a variable with the same name as our default + * (`__xphp_tag` / `__xphp_args`), pick a collision-free alternative + * derived from the template's start file pos (stable across runs + * of the same source). + * + * @param list $useClauses + * @return array{0: string, 1: string} + */ + private static function resolveDispatcherParamNames( + array $useClauses, + Closure|ArrowFunction $template, + ): array { + $captured = []; + foreach ($useClauses as $use) { + if ($use->var instanceof Variable && is_string($use->var->name)) { + $captured[$use->var->name] = true; + } + } + if (!isset($captured[self::TAG_PARAM_NAME]) && !isset($captured[self::ARGS_PARAM_NAME])) { + return [self::TAG_PARAM_NAME, self::ARGS_PARAM_NAME]; + } + $suffix = substr(hash('sha256', (string) $template->getStartFilePos()), 0, 8); + $tagName = self::TAG_PARAM_NAME . '_' . $suffix; + $argsName = self::ARGS_PARAM_NAME . '_' . $suffix; + return [$tagName, $argsName]; + } } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 902751f..e093723 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1170,16 +1170,21 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod // Eager rejections -- preserved from pre-P5.4 behavior so the // throw fires at the first offending call site, before the - // bag mutates. P5.5 / P5.6 will lift these for arrow + use - // forms once their dispatcher consumers ship. - if ($template instanceof ArrowFunction) { + // bag mutates. P5.5 lifted the arrow rejection by routing + // implicit captures through the dispatcher's `use (...)` + // clause; static closures and explicit `use (...)` closures + // are still pending (P5.6). + if ($template instanceof ArrowFunction && ClosureDispatcher::usesThis($template)) { + // P5.5 rejects `$this`-capturing generic arrows. The + // dispatcher closure can't carry `$this` through its + // `use` clause (PHP rejects `use ($this)`); a future + // commit can rewrite `$this->v` to a lifted param. throw new RuntimeException(sprintf( - 'Generic arrow functions cannot yet be specialized at ' - . 'call sites (capture-by-value semantics aren\'t ' - . 'preserved by the current hoist). Rewrite the call ' - . 'site for `$%s::<...>(...)` to use a named generic ' - . 'function (`function name(...) { ... }`) at file ' - . 'scope.', + 'Generic arrow `$%s::<...>(...)` captures `$this`, ' + . 'which is not yet supported. Rewrite as a method ' + . 'on the enclosing class, or extract the value of ' + . '$this->property into a local variable before ' + . 'the arrow.', $varName, )); } @@ -1222,6 +1227,14 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod $planKey = $varName . '@' . $template->getStartFilePos(); if (!isset($this->closureDispatchPlan[$planKey])) { + // Compute the dispatcher's `use (...)` clause once per + // template -- captures don't change between call sites. + // Arrows get implicit-capture analysis; closures use + // their explicit `use` list (empty for the capture-free + // case P5.4 shipped). + $useClauses = $template instanceof ArrowFunction + ? ClosureDispatcher::implicitCapturesOf($template) + : $template->uses; $this->closureDispatchPlan[$planKey] = [ 'template' => $template, 'varName' => $varName, @@ -1232,6 +1245,7 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod 'callSites' => [], 'argSets' => [], 'seenTagSet' => [], + 'useClauses' => $useClauses, ]; } $entry = &$this->closureDispatchPlan[$planKey]; @@ -1368,13 +1382,6 @@ private static function lastSegment(string $name): string * but never called via turbofish keeps its original Assign untouched, * so reflection on unused templates stays faithful. * - * @infection-ignore-all -- the `instanceof Closure ? $template->uses : []` - * ternary is observably identical until P5.6 lifts the `use ($x)` - * rejection: for the only flavor that reaches finalize in P5.4 - * (capture-free `function(...)`), `$template->uses === []` always - * (the rejection above filters out non-empty uses), so both branches - * produce the same `[]`. Same logic applies to ArrowFunction, which - * is rejected upstream. */ private function finalizeClosureDispatchers(object $visitor, int $hashLength): void { @@ -1384,9 +1391,7 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v continue; } $template = $entry['template']; - $useClauses = $template instanceof Closure - ? $template->uses - : []; + $useClauses = $entry['useClauses']; $result = $dispatcher->dispatch( $template, $entry['argSets'], diff --git a/test/Transpiler/Monomorphize/ArrowSpecializationTest.php b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php new file mode 100644 index 0000000..4bbc6a3 --- /dev/null +++ b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php @@ -0,0 +1,413 @@ +parseArrow('fn(int $x) => $x * 2'); + self::assertSame([], ClosureDispatcher::implicitCapturesOf($arrow)); + } + + public function testImplicitCapturesSingleFreeVar(): void + { + $arrow = $this->parseArrow('fn(int $x) => $x + $y'); + $captures = ClosureDispatcher::implicitCapturesOf($arrow); + self::assertCount(1, $captures); + self::assertSame('y', $captures[0]->var->name); + self::assertFalse($captures[0]->byRef); + } + + public function testImplicitCapturesExcludesArrowParams(): void + { + $arrow = $this->parseArrow('fn(int $x, int $y) => $x + $y'); + self::assertSame([], ClosureDispatcher::implicitCapturesOf($arrow)); + } + + public function testImplicitCapturesOrderedByFirstOccurrence(): void + { + // First-occurrence order: $z appears before $y in source. + $arrow = $this->parseArrow('fn(int $x) => $z + $y + $z'); + $names = array_map(fn (ClosureUse $u) => $u->var->name, ClosureDispatcher::implicitCapturesOf($arrow)); + self::assertSame(['z', 'y'], $names); + } + + public function testImplicitCapturesDoesNotDescendIntoNestedClosureBody(): void + { + // The inner Closure's body references `$w`, but its own `use ($w)` + // names `$w` explicitly. The analyzer SHOULD harvest `$w` from the + // inner closure's `use` clause (per the Round 10 reviewer fix), so + // the outer dispatcher's `use ($w)` brings it in. + $arrow = $this->parseArrow('fn(int $x) => $x + (function () use ($w) { return $w; })()'); + $names = array_map(fn (ClosureUse $u) => $u->var->name, ClosureDispatcher::implicitCapturesOf($arrow)); + self::assertSame(['w'], $names); + } + + public function testImplicitCapturesHarvestsFromNestedArrow(): void + { + // Inner arrow re-captures `$y` from our scope. + $arrow = $this->parseArrow('fn(int $x) => $x + (fn() => $y)()'); + $names = array_map(fn (ClosureUse $u) => $u->var->name, ClosureDispatcher::implicitCapturesOf($arrow)); + self::assertSame(['y'], $names); + } + + public function testImplicitCapturesSkipsThis(): void + { + $arrow = $this->parseArrow('fn(int $x) => $x + $this->v'); + // `$this` is intentionally excluded from the capture set; the + // GMC rejects arrows that need `$this` at the call-site path. + $names = array_map(fn (ClosureUse $u) => $u->var->name, ClosureDispatcher::implicitCapturesOf($arrow)); + self::assertNotContains('this', $names); + } + + // ----- end-to-end integration tests ----------------------------------- + + public function testArrowSpecializationEndToEndSingleCapture(): void + { + // The sprint plan's canonical test: + // $y = 1; $id = fn(T $x): T => $x + $y; $y = 2; $id::(42); + // Expected: 43 (capture moment is the arrow's evaluation, not the call). + $dir = $this->mkdir('arrow-capture'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $y; + $y = 2; + $result = $id::(42); + PHP); + + $this->compile($dir); + + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + // Capture-at-declaration: $y inside the closure stayed at 1 + // even though the outer $y reassigned to 2 before the call. + self::assertContains('result=43;y=2;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationEndToEndMultipleCaptures(): void + { + $dir = $this->mkdir('arrow-multi'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $a + $b; + $result = $f::(1); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=31;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationEndToEndNoCaptures(): void + { + $dir = $this->mkdir('arrow-empty'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $result = $f::(42); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=42;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationCaptureShadowingParamName(): void + { + // Param wins -- the outer $x = 99 is NOT captured because `x` + // is in the arrow's param-set. + $dir = $this->mkdir('arrow-shadow'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $result = $f::(7); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=7;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationMultipleArgTuples(): void + { + $dir = $this->mkdir('arrow-multitup'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $a = $f::(10); + $b = $f::('hi'); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(2, $matches[0]); + + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('a=10;b=hi;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationThisCaptureRejected(): void + { + // P5.5 rejects `$this`-capturing generic arrows. The error + // points users at lifting to a method or extracting the + // property value. + $dir = $this->mkdir('arrow-this'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $this->v; + return $f::(2); + } + } + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('captures `$this`'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationReservedArgsCaptureAlsoTriggersRename(): void + { + // Symmetric to the `__xphp_tag` test -- a capture named + // `__xphp_args` collides with the dispatcher's variadic param. + // Both the tag AND args params get renamed together. + $dir = $this->mkdir('arrow-reserved-args'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $__xphp_args; + $result = $f::(3); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=203;', $output); + + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertMatchesRegularExpression( + '/mixed \.\.\.\$__xphp_args_[0-9a-f]{8}/', + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowSpecializationReservedCaptureAutoRenamesDispatcherParam(): void + { + // Capture name `__xphp_tag` collides with the dispatcher's + // tag param. The dispatcher should auto-rename its own tag + // param to a collision-free alternative; the user's + // `$__xphp_tag` keeps its name. + $dir = $this->mkdir('arrow-reserved'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $__xphp_tag; + $result = $f::(5); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=105;', $output); + + // The dispatcher's tag param was renamed (not the default). + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertMatchesRegularExpression( + '/string \$__xphp_tag_[0-9a-f]{8}/', + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + // ----- helpers -------------------------------------------------------- + + private function parseArrow(string $source): ArrowFunction + { + $parser = (new ParserFactory())->createForHostVersion(); + $stmts = $parser->parse('expr; + self::assertInstanceOf(ArrowFunction::class, $arrow); + return $arrow; + } + + private function mkdir(string $tag): string + { + $root = sys_get_temp_dir() . '/xphp-' . $tag . '-' . uniqid('', true); + $src = $root . '/src'; + mkdir($src, 0o755, true); + return $src; + } + + private function compile(string $sourceDir): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile( + $sources, + $sourceDir, + dirname($sourceDir) . '/dist', + dirname($sourceDir) . '/.xphp-cache', + ); + $dist = dirname($sourceDir) . '/dist'; + if (is_dir($dist) && !is_dir($sourceDir . '/dist')) { + symlink($dist, $sourceDir . '/dist'); + } + } + + /** + * @return array{0: int, 1: list} + */ + private function execScript(string $script): array + { + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($script) . ' 2>&1', $output, $exit); + return [$exit, $output]; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir) && !is_link($dir)) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + if (is_link($path)) { + unlink($path); + } elseif (is_dir($path)) { + $this->rrmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index 39288cd..ffb30fe 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -199,8 +199,10 @@ public function testUnknownTagAtRuntimeThrows(): void $this->rrmdir(dirname($dir)); } - public function testArrowRejectionStillFires(): void + public function testArrowSpecializesViaDispatcher(): void { + // P5.5: arrow rejection lifted. Capture-free arrow specializes + // through the same dispatcher path as capture-free closures. $dir = $this->mkdir('disp-arrow'); file_put_contents($dir . '/Use.xphp', <<<'PHP' (1); PHP); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Generic arrow functions cannot yet be specialized'); $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + self::assertStringContainsString('closure_id_T_', $out); + self::assertStringContainsString('__xphp_tag', $out); $this->rrmdir(dirname($dir)); } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index ecaba24..95a6b33 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2418,8 +2418,11 @@ public function testGenericClosureWithoutUseHoistsAndSpecializes(): void self::rrmdir($workDir); } - public function testGenericArrowFunctionRejectedAtCallSite(): void + public function testGenericArrowFunctionSpecializesViaDispatcher(): void { + // P5.5: generic arrow functions now specialize end-to-end via the + // P5.4 dispatcher. The arrow's implicit captures (none here) are + // synthesized as a `use (...)` clause on the dispatcher closure. $workDir = sys_get_temp_dir() . '/xphp-arrow-' . uniqid('', true); mkdir($workDir, 0o755, true); $sourceDir = $workDir . '/src'; @@ -2445,9 +2448,17 @@ public function testGenericArrowFunctionRejectedAtCallSite(): void $sources = (new \XPHP\FileSystem\FileFinder\NativeFileFinder())->find($sourceDir) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Generic arrow functions cannot yet be specialized'); $compiler->compile($sources, $sourceDir, $workDir . '/dist', $workDir . '/.xphp-cache'); + + $rewritten = file_get_contents($workDir . '/dist/Use.php'); + self::assertStringContainsString('closure_id_T_', $rewritten); + self::assertStringContainsString('__xphp_tag', $rewritten); + // Call site rewritten with the tag prefix. + self::assertMatchesRegularExpression( + "/\\\$id\\('T_[0-9a-f]+', 42\\)/", + $rewritten, + ); + self::rrmdir($workDir); } From 3cf8ec746e27c0e2f5f30b2e4868873b2fac6790 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 13:12:50 +0000 Subject: [PATCH 25/36] feat(specializer): specialize closures with use() clauses incl. by-ref Pre-this commit: generic closures carrying an explicit `use (...)` clause rejected at GMC with "captures aren't preserved by the top-level hoist". P5.4 + P5.5 built the dispatcher infrastructure to handle implicit captures (arrows) and lifted trailing params. P5.6 is the smallest additive step: the closure already names its captures via `$template->uses`, so no analyzer is needed. The rejection block is removed; the dispatcher forwards `$template->uses` onto its own `use` clause; each capture's `byRef` flag propagates through both the dispatcher's `use (&$y)` AND the lifted trailing param `mixed &$y`, preserving PHP reference semantics so the body's mutations still write through to the outer scope. `ClosureDispatcher::usesThis()` widened to `Closure|ArrowFunction`: walks `$template->stmts` for closures, `$template->expr` for arrows. GMC's `$this` rejection now applies uniformly to both flavors with a flavor-aware error message. The `static` closure rejection stays unchanged (no `$this` binding target). 8 new tests in `UseClosureSpecializationTest.php` cover by-value and by-ref captures (with runtime mutation verification), mixed ref/value mixes, multiple arg tuples, the static-closure rejection regression, the new `$this`-capturing closure rejection, and the auto-rename collision path for `$__xphp_args` captures. Existing `testGenericClosureWithUseClauseRejectedAtCallSite` flipped to assert successful specialization. Closes caveat C7 and deferred work item D3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/ClosureDispatcher.php | 42 ++- .../Monomorphize/GenericMethodCompiler.php | 31 +- .../ClosureDispatcherIntegrationTest.php | 12 +- .../UseClosureSpecializationTest.php | 324 ++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 19 +- 5 files changed, 388 insertions(+), 40 deletions(-) create mode 100644 test/Transpiler/Monomorphize/UseClosureSpecializationTest.php diff --git a/src/Transpiler/Monomorphize/ClosureDispatcher.php b/src/Transpiler/Monomorphize/ClosureDispatcher.php index 93c0eb6..02700d6 100644 --- a/src/Transpiler/Monomorphize/ClosureDispatcher.php +++ b/src/Transpiler/Monomorphize/ClosureDispatcher.php @@ -226,15 +226,20 @@ private static function syntheticFunctionFromTemplate( $returnType = $template->returnType; $templateParams = $template->params; } - // Lift each `use ($x)` capture into a trailing `mixed $x` param so - // the dispatcher can forward its captured snapshot at call time. - // Captures pulled from the dispatcher's `use` clause -- not from - // the closure's own `uses` list (which is only populated on - // Closure templates, not arrows). + // Lift each `use ($x)` / `use (&$x)` capture into a trailing + // `mixed $x` / `mixed &$x` param so the dispatcher can forward + // its captured snapshot at call time. `byRef` is preserved from + // the user's original `use` clause -- crucial for P5.6 where + // `use (&$y)` lets the body mutate the outer scope. Captures + // are pulled from the dispatcher's `use` clause (set by the + // caller); for arrows we synthesize them via the implicit- + // capture analyzer, for `use(...)`-closures we forward + // `$template->uses` verbatim. foreach ($useClauses as $use) { $templateParams[] = new Param( $use->var, type: new Identifier('mixed'), + byRef: $use->byRef, ); } $synthetic = new Function_( @@ -465,13 +470,19 @@ public function enterNode(\PhpParser\Node $node): ?int * same outcome because nested closures' `$this` is irrelevant; the * is_string + equality short-circuit is the standard idiom). * - * True iff the arrow's body references `$this` (transitively, including - * inside nested arrows whose own params don't shadow it). Used by GMC - * to reject `$this`-capturing generic arrows before they reach - * `implicitCapturesOf` -- the analyzer intentionally drops `$this` - * because the dispatcher can't carry it via a `use` clause. + * True iff the template's body references `$this`. Used by GMC to + * reject `$this`-capturing generic arrows and `use (...)`-closures + * before they reach the dispatcher path -- PHP doesn't allow + * `use ($this)`, and the specialized top-level function can't see + * the enclosing class's `$this`. + * + * For `ArrowFunction`, walks the single body expression. + * For `Closure`, walks every statement in `$template->stmts`. + * Either way, descent stops at nested regular `Closure` boundaries + * (a nested closure's `$this` is bound at ITS own construction + * time, not ours). */ - public static function usesThis(ArrowFunction $arrow): bool + public static function usesThis(Closure|ArrowFunction $template): bool { $found = false; $traverser = new \PhpParser\NodeTraverser(); @@ -483,10 +494,6 @@ public function __construct(private bool &$found) public function enterNode(\PhpParser\Node $node): ?int { if ($node instanceof Closure) { - // Regular closures have their own `$this` scope; don't - // descend into them. A nested closure's `$this` is - // bound at the closure's own construction time, not - // ours. return \PhpParser\NodeTraverser::DONT_TRAVERSE_CHILDREN; } if ($node instanceof Variable @@ -498,7 +505,10 @@ public function enterNode(\PhpParser\Node $node): ?int return null; } }); - $traverser->traverse([$arrow->expr]); + $nodes = $template instanceof ArrowFunction + ? [$template->expr] + : $template->stmts; + $traverser->traverse($nodes); return $found; } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index e093723..f1e6dba 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1174,18 +1174,24 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod // implicit captures through the dispatcher's `use (...)` // clause; static closures and explicit `use (...)` closures // are still pending (P5.6). - if ($template instanceof ArrowFunction && ClosureDispatcher::usesThis($template)) { - // P5.5 rejects `$this`-capturing generic arrows. The - // dispatcher closure can't carry `$this` through its - // `use` clause (PHP rejects `use ($this)`); a future - // commit can rewrite `$this->v` to a lifted param. + if (ClosureDispatcher::usesThis($template)) { + // P5.5 / P5.6 reject `$this`-capturing generic + // anonymous templates. The dispatcher closure can't + // carry `$this` through its `use` clause (PHP rejects + // `use ($this)`); the specialized top-level function + // also can't see the enclosing class's `$this`. + // A future commit can rewrite `$this->v` to a lifted + // param. + $flavor = $template instanceof ArrowFunction ? 'arrow' : 'closure'; throw new RuntimeException(sprintf( - 'Generic arrow `$%s::<...>(...)` captures `$this`, ' + 'Generic %s `$%s::<...>(...)` captures `$this`, ' . 'which is not yet supported. Rewrite as a method ' . 'on the enclosing class, or extract the value of ' . '$this->property into a local variable before ' - . 'the arrow.', + . 'the %s.', + $flavor, $varName, + $flavor, )); } if ($template instanceof Closure && $template->static) { @@ -1196,17 +1202,6 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod $varName, )); } - if ($template instanceof Closure && $template->uses !== []) { - throw new RuntimeException(sprintf( - 'Generic closures with `use (...)` clauses cannot yet ' - . 'be specialized at call sites (captures aren\'t ' - . 'preserved by the top-level hoist). Rewrite the call ' - . 'site for `$%s::<...>(...)` to use a named generic ' - . 'function, or drop the `use` clause and read the ' - . 'captured values from inside the body.', - $varName, - )); - } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params) || count($params) !== count($args)) { diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index ffb30fe..2f1d972 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -219,8 +219,12 @@ public function testArrowSpecializesViaDispatcher(): void $this->rrmdir(dirname($dir)); } - public function testUseClauseRejectionStillFires(): void + public function testUseClauseClosureSpecializesViaDispatcher(): void { + // P5.6: closure-with-`use` rejection lifted. The dispatcher + // forwards the user's `use (...)` clause onto itself and lifts + // each capture as a trailing `mixed` param on the specialized + // function. $dir = $this->mkdir('disp-use'); file_put_contents($dir . '/Use.xphp', <<<'PHP' (42); PHP); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('clauses cannot yet be specialized'); $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + self::assertStringContainsString('closure_f_T_', $out); + self::assertStringContainsString('use ($y)', $out); $this->rrmdir(dirname($dir)); } diff --git a/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php b/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php new file mode 100644 index 0000000..8f521a5 --- /dev/null +++ b/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php @@ -0,0 +1,324 @@ +mkdir('use-byval'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($y) { return $x + $y; }; + $y = 2; + $result = $f::(42); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('result=43;y=2;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseByRefCaptureMutatesOuter(): void + { + // `use (&$y)`: mutations inside the body propagate to the outer + // scope. The dispatcher's `use (&$y)` plus the lifted `mixed &$y` + // param + named-arg forwarding preserves the reference all the + // way to the specialized function's body. + $dir = $this->mkdir('use-byref'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use (&$y): T { + $y = $x; + return $y; + }; + $a = $f::(99); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('a=99;y=99;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseByRefIsEmittedInBothDispatcherAndSpecialization(): void + { + // Inspect the compiled PHP shape to confirm `byRef` propagates + // through both layers. + $dir = $this->mkdir('use-byref-emit'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use (&$y): T { return $x; }; + $f::(1); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + + // Dispatcher's use clause has the `&`. + self::assertStringContainsString('use (&$y)', $out); + // Specialized function declares the lifted param with `&`. + self::assertMatchesRegularExpression( + '/function closure_f_T_[0-9a-f]+\(int \$x, mixed &\$y\)/', + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseMultipleMixedRefAndValueCaptures(): void + { + $dir = $this->mkdir('use-mixed'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($a, &$b) { + $b = $b + 5; // mutates outer $b + return $x + $a + $b; + }; + $r = $f::(1); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + // r = 1 + 10 + 25 = 36; a stays at 10; b mutated to 25. + self::assertContains('r=36;a=10;b=25;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseMultipleArgTuples(): void + { + $dir = $this->mkdir('use-tuples'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($tag) { return $tag . ':' . $x; }; + $a = $f::(1); + $b = $f::('two'); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(2, $matches[0]); + + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('a=pre:1;b=pre:two;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseStaticClosureStillRejected(): void + { + // P5.6 leaves the static-closure rejection in place. PHP's + // `static function() use ($y) { ... }` semantics prevent + // `$this` binding, which isn't a target we ship in this commit. + $dir = $this->mkdir('use-static'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($y) { return $x + $y; }; + $f::(1); + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic static closures cannot yet be specialized'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseClosureCapturingThisRejected(): void + { + // Closures (unlike arrows) can be bound to a `$this`. P5.6 + // rejects them eagerly because PHP doesn't allow `use ($this)` + // and the specialized top-level function can't see the + // enclosing class's `$this`. + $dir = $this->mkdir('use-this'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): int { + return $x + $this->v; + }; + return $f::(2); + } + } + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('captures `$this`'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testUseClauseCaptureNamedXphpArgsAutoRenames(): void + { + // Regression guard: a user variable captured via `use ($__xphp_args)` + // collides with the dispatcher's own variadic param name. The + // auto-rename machinery from P5.5 applies to closures too. + $dir = $this->mkdir('use-reserved'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($__xphp_args) { return $x + $__xphp_args; }; + $r = $f::(3); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('r=203;', $output); + + $this->rrmdir(dirname($dir)); + } + + // ----- helpers -------------------------------------------------------- + + private function mkdir(string $tag): string + { + $root = sys_get_temp_dir() . '/xphp-' . $tag . '-' . uniqid('', true); + $src = $root . '/src'; + mkdir($src, 0o755, true); + return $src; + } + + private function compile(string $sourceDir): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile( + $sources, + $sourceDir, + dirname($sourceDir) . '/dist', + dirname($sourceDir) . '/.xphp-cache', + ); + $dist = dirname($sourceDir) . '/dist'; + if (is_dir($dist) && !is_dir($sourceDir . '/dist')) { + symlink($dist, $sourceDir . '/dist'); + } + } + + /** + * @return array{0: int, 1: list} + */ + private function execScript(string $script): array + { + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($script) . ' 2>&1', $output, $exit); + return [$exit, $output]; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir) && !is_link($dir)) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + if (is_link($path)) { + unlink($path); + } elseif (is_dir($path)) { + $this->rrmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 95a6b33..7223941 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2462,8 +2462,12 @@ public function testGenericArrowFunctionSpecializesViaDispatcher(): void self::rrmdir($workDir); } - public function testGenericClosureWithUseClauseRejectedAtCallSite(): void + public function testGenericClosureWithUseClauseSpecializesViaDispatcher(): void { + // P5.6: closures with explicit `use (...)` now specialize via + // the dispatcher path; the `use` clause is forwarded onto the + // dispatcher closure and each capture is lifted as a trailing + // `mixed` param on the specialized function. $workDir = sys_get_temp_dir() . '/xphp-closure-use-' . uniqid('', true); mkdir($workDir, 0o755, true); $sourceDir = $workDir . '/src'; @@ -2490,9 +2494,18 @@ public function testGenericClosureWithUseClauseRejectedAtCallSite(): void $sources = (new \XPHP\FileSystem\FileFinder\NativeFileFinder())->find($sourceDir) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('clauses cannot yet be specialized'); $compiler->compile($sources, $sourceDir, $workDir . '/dist', $workDir . '/.xphp-cache'); + + $rewritten = file_get_contents($workDir . '/dist/Use.php'); + self::assertStringContainsString('closure_f_T_', $rewritten); + // Dispatcher closure's `use ($y)` clause forwards the capture. + self::assertStringContainsString('use ($y)', $rewritten); + // Specialized function carries the lifted `mixed $y` trailing param. + self::assertMatchesRegularExpression( + '/function closure_f_T_[0-9a-f]+\(int \$x, mixed \$y\)/', + $rewritten, + ); + self::rrmdir($workDir); } From 3b5fa5937da50419fb032be741bd27f41ef8c70c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 13:23:02 +0000 Subject: [PATCH 26/36] feat(parser): allow defaults on closures and arrows Pre-this commit: generic anonymous closures and arrow functions rejected `T = Default` at parse time with "not yet supported on closures or arrow functions". P5.5 + P5.6 shipped the specialization paths for both forms via the P5.4 dispatcher. P5.7 lifts the per-form rejection: anonymous closures (`function(...)`) and arrows (`fn(...)`) now allow defaults at parse time. `static function` still rejects per the per-form gating rule from the sprint plan (its specialization path didn't ship). The call-site rewrite path in `GenericMethodCompiler::rewriteFuncCall` distinguishes variable-turbofish (`$f::<>()`) from named-call: the former allows empty `$args` through to the recorder, which pads via `Registry::padArgsWithDefaults` before the arity check. Each call site in the dispatch-plan bag is paired with its post-padding tag so the empty-turbofish all-defaults shape routes to the right specialization arm. The error message updates to point users at the still-rejected `static` closure case. 10 new tests in `ClosureArrowDefaultsTest.php` cover single defaults, trailing defaults that pad at the call site, defaults referring to earlier params, missing-required-param error, the per-form-gated static-closure rejection, and defaults composed with `use (...)` and arrow implicit-capture features. Closes caveat C4 and deferred work item D4. After this commit, all seven Phase 5 work items (P5.1 - P5.7) are complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 53 +++- .../Monomorphize/XphpSourceParser.php | 23 +- .../Monomorphize/ClosureArrowDefaultsTest.php | 277 ++++++++++++++++++ .../Monomorphize/XphpSourceParserTest.php | 27 +- 4 files changed, 351 insertions(+), 29 deletions(-) create mode 100644 test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index f1e6dba..408d90d 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1066,16 +1066,26 @@ private function resolveReceiverFqn(Node $receiver): ?string private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); - if (!is_array($args) || $args === [] || !self::allConcrete($args)) { + if (!is_array($args)) { + return null; + } + $isVarTurbofish = $node->name instanceof Variable && is_string($node->name->name); + // Empty turbofish (`$f::<>(...)`) is the all-defaults shape for + // variable-turbofish call sites (P5.7); the dispatcher path + // pads via `Registry::padArgsWithDefaults`. For named-call + // turbofish, empty-args is still invalid -- the original + // call-site rewriter expects concrete args. + if (!$isVarTurbofish && ($args === [] || !self::allConcrete($args))) { + return null; + } + if ($isVarTurbofish && $args !== [] && !self::allConcrete($args)) { return null; } - // Variable turbofish `$var::(...)`: dispatched to a separate - // path that looks up the variable's tracked closure template - // and hoists the body to a top-level Function_. Arrows and - // closures with `use`/static are rejected with a clear - // compile-time error (capture semantics aren't preserved by - // the hoist). - if ($node->name instanceof Variable && is_string($node->name->name)) { + // Variable turbofish `$var::(...)` / `$var::<>(...)`: + // dispatched to a separate path that looks up the variable's + // tracked closure template and routes through the P5.4 + // dispatcher. Defaults pad missing trailing args (P5.7). + if ($isVarTurbofish) { return $this->rewriteVariableTurbofishCall($node, $args); } if (!$node->name instanceof Name) { @@ -1204,7 +1214,16 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - if (!is_array($params) || count($params) !== count($args)) { + if (!is_array($params)) { + return null; + } + // P5.7: pad missing trailing args with defaults BEFORE + // the arity check so `$f::<>()` works on an all-defaulted + // generic closure / arrow. Padding throws when leading + // required params are missing -- the throw surfaces with + // a clear `Registry::padArgsWithDefaults` message. + $args = Registry::padArgsWithDefaults($params, $args, 'closure<' . $varName . '>'); + if (count($params) !== count($args)) { return null; } if ($this->hierarchy !== null) { @@ -1249,7 +1268,10 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Nod $entry['seenTagSet'][$tag] = true; $entry['argSets'][] = $args; } - $entry['callSites'][] = $node; + // Pair each call site with its PADDED tag so finalize doesn't + // recompute from the original `ATTR_METHOD_GENERIC_ARGS` (which + // may be empty for the `$f::<>()` default-padding shape). + $entry['callSites'][] = ['node' => $node, 'tag' => $tag]; unset($entry); // Do NOT mutate the call site yet -- pass 2 prepends the tag arg // once the dispatcher's specializations are known. @@ -1410,11 +1432,12 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v // Rewrite every recorded call site: prepend the tag arg, clear // the turbofish marker. The Variable receiver stays so the // dispatcher closure (now in `$varName`) is the call target. - foreach ($entry['callSites'] as $callSite) { - $tag = ClosureDispatcher::tagFor( - $callSite->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS), - $hashLength, - ); + // Each call site carries its post-padding tag (computed at + // record time) so empty-turbofish defaults still route to + // the right specialization arm. + foreach ($entry['callSites'] as $callSiteEntry) { + $callSite = $callSiteEntry['node']; + $tag = $callSiteEntry['tag']; $tagArg = new Arg(new String_($tag)); array_unshift($callSite->args, $tagArg); $callSite->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 806c9ac..b6f8992 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -188,10 +188,14 @@ private function scanAndStrip(string $source): array $anchorLine = $tok->line; $j = self::skipWs($tokens, $i + 1); if ($j < $n && $tokens[$j]->text === '<') { + // P5.7: defaults allowed on anonymous closures + arrows + // because their specialization paths shipped in + // P5.5 / P5.6. `padArgsWithDefaults` at GMC call-site + // time pads missing trailing args. $parsed = self::parseTypeParamList( $tokens, $j, - allowDefaults: false, + allowDefaults: true, allowVariance: false, ); if ($parsed !== null) { @@ -613,8 +617,8 @@ private static function parseTypeParamList( if (!$allowDefaults) { throw new RuntimeException(sprintf( 'Generic parameter `%s` has a default value, which is not yet ' - . 'supported on closures or arrow functions. Assign the closure ' - . 'to a named function or remove the default.', + . 'supported on static closures. Drop the `static` modifier or ' + . 'assign the closure to a named function.', $paramName, )); } @@ -1413,11 +1417,14 @@ public function enterNode(Node $node): null $typeParams = []; foreach ($marker['params'] as $entry) { $bound = $this->buildBoundExpr($entry); - // Method/function/closure/arrow entries never - // carry variance markers (parseTypeParamList rejects - // with allowVariance: false). Methods/functions - // can carry defaults; closures/arrows cannot - // (allowDefaults: false on the latter two). + // Method / function / closure / arrow entries + // never carry variance markers + // (parseTypeParamList rejects with + // `allowVariance: false`). Defaults are allowed + // on methods, functions, anonymous closures + // (P5.7), and arrows (P5.7); only `static` + // closures still reject defaults at parse time + // because their specialization path doesn't ship. $default = $this->buildDefault($entry); $typeParams[] = new TypeParam( $entry['name'], diff --git a/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php new file mode 100644 index 0000000..11f0885 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php @@ -0,0 +1,277 @@ +(...)`, `fn(...)`). + */ +final class ClosureArrowDefaultsTest extends TestCase +{ + public function testClosureWithSingleDefaultUsesPadding(): void + { + $dir = $this->mkdir('cdef-single'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { return $x; }; + $r = $f::<>(42); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('r=42;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testArrowWithSingleDefaultUsesPadding(): void + { + $dir = $this->mkdir('adef-single'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x; + $r = $f::<>(7); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('r=7;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testClosureWithTrailingDefaultPadsAtCallSite(): void + { + $dir = $this->mkdir('cdef-trail'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (A $a, B $b): array { return [$a, $b]; }; + $r = $f::(10, 'hi'); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + // Padded to -- the specialized function reflects that. + self::assertMatchesRegularExpression( + '/function closure_f_T_[0-9a-f]+\(int \$a, string \$b\)/', + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + public function testClosureWithDefaultReferringToEarlierParam(): void + { + $dir = $this->mkdir('cdef-ref'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (A $a, B $b): array { return [$a, $b]; }; + $r = $f::(1, 2); + PHP); + + $this->compile($dir); + $out = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($out); + // B padded to int (same as A), so both params end up as int. + self::assertMatchesRegularExpression( + '/function closure_f_T_[0-9a-f]+\(int \$a, int \$b\)/', + $out, + ); + + $this->rrmdir(dirname($dir)); + } + + public function testMissingRequiredParamRaises(): void + { + // Function -- A is required, no default. Calling + // with `<>` must error: padding fails on missing required A. + $dir = $this->mkdir('cdef-req'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (A $a, B $b): array { return [$a, $b]; }; + $r = $f::<>(1, 2); + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no default'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testStaticClosureDefaultStillRejectedAtParse(): void + { + // Per-form gating: static-closure specialization didn't ship, + // so defaults stay rejected for the `static function` form. + $dir = $this->mkdir('cdef-static'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T { return $x; }; + PHP); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('static closures'); + $this->compile($dir); + $this->rrmdir(dirname($dir)); + } + + public function testDefaultOnClosureWithUseClause(): void + { + // Defaults + use(): both features compose. + $dir = $this->mkdir('cdef-use'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x) use ($base) { return $x + $base; }; + $r = $f::<>(5); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('r=105;', $output); + + $this->rrmdir(dirname($dir)); + } + + public function testDefaultOnArrowWithImplicitCapture(): void + { + // Defaults + arrow implicit captures together. + $dir = $this->mkdir('adef-cap'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $y; + $r = $f::<>(3); + PHP); + + $this->compile($dir); + $runScript = $dir . '/run.php'; + file_put_contents($runScript, <<execScript($runScript); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + self::assertContains('r=13;', $output); + + $this->rrmdir(dirname($dir)); + } + + // ----- helpers -------------------------------------------------------- + + private function mkdir(string $tag): string + { + $root = sys_get_temp_dir() . '/xphp-' . $tag . '-' . uniqid('', true); + $src = $root . '/src'; + mkdir($src, 0o755, true); + return $src; + } + + private function compile(string $sourceDir): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile( + $sources, + $sourceDir, + dirname($sourceDir) . '/dist', + dirname($sourceDir) . '/.xphp-cache', + ); + $dist = dirname($sourceDir) . '/dist'; + if (is_dir($dist) && !is_dir($sourceDir . '/dist')) { + symlink($dist, $sourceDir . '/dist'); + } + } + + /** + * @return array{0: int, 1: list} + */ + private function execScript(string $script): array + { + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($script) . ' 2>&1', $output, $exit); + return [$exit, $output]; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir) && !is_link($dir)) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + if (is_link($path)) { + unlink($path); + } elseif (is_dir($path)) { + $this->rrmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 7223941..d3848d2 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2597,29 +2597,44 @@ public function testGenericArrowFunctionIsParsed(): void self::assertSame('T', $params[0]->name); } - public function testGenericClosureDefaultIsRejected(): void + public function testGenericClosureDefaultIsAccepted(): void { + // P5.7: defaults now allowed on anonymous closures; GMC pads + // missing trailing args via `Registry::padArgsWithDefaults`. $source = <<<'PHP' (T $x): T { return $x; }; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('closures or arrow functions'); - $parser->parse($source); + $ast = $parser->parse($source); + self::assertNotEmpty($ast); } - public function testGenericArrowFunctionDefaultIsRejected(): void + public function testGenericArrowFunctionDefaultIsAccepted(): void { $source = <<<'PHP' (T $x): T => $x; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + self::assertNotEmpty($ast); + } + + public function testGenericStaticClosureDefaultStillRejected(): void + { + // P5.7's per-form gating: static closures still reject defaults + // because their specialization didn't ship. + $source = <<<'PHP' +(T $x): T { return $x; }; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('closures or arrow functions'); + $this->expectExceptionMessage('static closures'); $parser->parse($source); } From 4a4b40177ee6c0945115bd9e22bf9c778c21b815 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 13:47:31 +0000 Subject: [PATCH 27/36] test(fixture): durable artifacts for the four dispatcher consumers The Phase 5 P5.4 - P5.7 features are well-covered by inline-source tests under `test/Transpiler/Monomorphize/`, but `test/fixture/compile/` had only the `closure_generic/` directory whose comment described the pre-P5.4 streaming-hoist mechanic that no longer matches the emitted code. This commit: - Refreshes the `closure_generic/source/Use.xphp` header comment to describe the dispatcher-routed shape (sentinel-arg-prefix `match` on `T_`, top-level `closure_pair_T_` specializations). - Adds three new fixtures under `test/fixture/compile/` mirroring the remaining three dispatcher consumers: closure_dispatcher_arrow (P5.5: implicit captures) closure_dispatcher_use_clause (P5.6: explicit use(), incl. &-ref) closure_dispatcher_defaults (P5.7: defaults + empty turbofish) - Wires a `DispatcherFixtureIntegrationTest` that compiles each fixture through `Compiler::compile` and asserts on both the emitted PHP shape AND runtime behavior (running the compiled output through `exec`). The use-clause and defaults fixtures' runtime checks pin the by-ref mutation contract and the empty-turbofish routing. 418 -> 422 tests (4 new fixture tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DispatcherFixtureIntegrationTest.php | 213 ++++++++++++++++++ .../closure_dispatcher_arrow/source/Use.xphp | 19 ++ .../source/Use.xphp | 25 ++ .../source/Use.xphp | 23 ++ .../compile/closure_generic/source/Use.xphp | 11 +- 5 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php create mode 100644 test/fixture/compile/closure_dispatcher_arrow/source/Use.xphp create mode 100644 test/fixture/compile/closure_dispatcher_defaults/source/Use.xphp create mode 100644 test/fixture/compile/closure_dispatcher_use_clause/source/Use.xphp diff --git a/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php new file mode 100644 index 0000000..b56dc4c --- /dev/null +++ b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php @@ -0,0 +1,213 @@ +workDir = sys_get_temp_dir() . '/xphp-fixture-disp-' . uniqid('', true); + $this->targetDir = $this->workDir . '/dist'; + $this->cacheDir = $this->workDir . '/.xphp-cache'; + mkdir($this->workDir, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->workDir)) { + self::rrmdir($this->workDir); + } + } + + public function testCaptureFreeFixtureRoutesViaDispatcher(): void + { + // Fixture: `closure_generic/`. The original P5.4 consumer -- + // a capture-free `function(...)` with two duplicate-tuple + // call sites that dedupe to a single specialization. + $out = $this->compileFixture('closure_generic'); + + self::assertMatchesRegularExpression( + '/function closure_pair_T_[0-9a-f]+\(string \$key, int \$value\): array/', + $out, + ); + self::assertStringContainsString('__xphp_tag', $out); + self::assertStringContainsString('match ($__xphp_tag)', $out); + // Two same-tuple calls dedupe to one specialization. + preg_match_all('/function closure_pair_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(1, $matches[0]); + // Both call sites carry the tag prefix. + preg_match_all("/\\\$pair\\('T_[0-9a-f]+', /", $out, $callMatches); + self::assertCount(2, $callMatches[0]); + } + + public function testArrowFixtureSynthesizesUseClauseFromImplicitCapture(): void + { + // Fixture: `closure_dispatcher_arrow/`. The arrow body references + // `$y` from the outer scope; the analyzer harvests it and the + // dispatcher's `use ($y)` snapshots the value at the assign site. + $out = $this->compileFixture('closure_dispatcher_arrow'); + + // Dispatcher carries the synthesized `use ($y)` clause. + self::assertMatchesRegularExpression( + '/function \(string \$__xphp_tag, mixed \.\.\.\$__xphp_args\) use \(\$y\)/', + $out, + ); + // Specialized fn declares the lifted `mixed $y` trailing param. + self::assertMatchesRegularExpression( + '/function closure_id_T_[0-9a-f]+\(int \$x, mixed \$y\)/', + $out, + ); + + // Runtime: the capture moment is the assign site, so the call + // sees y=1 even though the outer y reassigns to 2 before it. + $output = $this->execCompiled($this->workDir . '/dist/Use.php', 'echo "r={$resultArrow};y={$y};";'); + self::assertContains('r=43;y=2;', $output); + } + + public function testUseClauseFixturePropagatesByRefThroughDispatcher(): void + { + // Fixture: `closure_dispatcher_use_clause/`. The `use (&$counter)` + // by-ref capture must survive: dispatcher's `use (&$counter)`, + // lifted param `mixed &$counter`, named-arg forwarding to the + // specialized fn -- all preserve ref-ness so the outer $counter + // mutates. + $out = $this->compileFixture('closure_dispatcher_use_clause'); + + // Dispatcher's `use` clause carries both the by-value $base AND + // the by-ref &$counter. + self::assertStringContainsString('use ($base, &$counter)', $out); + // Specialized fns declare the lifted params with matching ref-ness. + self::assertMatchesRegularExpression( + '/function closure_f_T_[0-9a-f]+\([^)]+, mixed \$base, mixed &\$counter\)/', + $out, + ); + + // Two distinct specializations (T=int and T=string). + preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $matches); + self::assertCount(2, $matches[0]); + + // Runtime: $counter mutates across the three calls; the int + // specialization handles calls A + B; the string specialization + // handles call C; both observe the live $counter. + $output = $this->execCompiled( + $this->workDir . '/dist/Use.php', + 'echo "A=" . implode(",", $callA) . ";B=" . implode(",", $callB) . ";C=" . implode(",", $callC) . ";counter={$counter};";', + ); + self::assertContains('A=1,10,1;B=2,10,2;C=hi,10,3;counter=3;', $output); + } + + public function testDefaultsFixturePadsEmptyTurbofish(): void + { + // Fixture: `closure_dispatcher_defaults/`. Empty-turbofish + // `$f::<>()` calls trigger `Registry::padArgsWithDefaults` at + // record time; the per-call-site tag stash ensures the runtime + // tag matches the dispatcher arm built from the padded tuple. + $out = $this->compileFixture('closure_dispatcher_defaults'); + + // The empty turbofish `$f::<>(42)` and the explicit `$f::('hi')` + // produce two distinct specializations on $f. + preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $fMatches); + self::assertCount(2, $fMatches[0]); + + // The arrow $g specializes against the default (string). + self::assertMatchesRegularExpression( + '/function closure_g_T_[0-9a-f]+\(string \$x\): string/', + $out, + ); + + // Runtime: each call routes to the right specialization and + // returns the expected value. + $output = $this->execCompiled( + $this->workDir . '/dist/Use.php', + 'echo "c1={$resultPaddedClosure};c2={$resultExplicitClosure};a={$resultPaddedArrow};";', + ); + self::assertContains('c1=#42;c2=#hi;a=world;', $output); + } + + // ----- helpers --------------------------------------------------------- + + private function compileFixture(string $name): string + { + $sourceDir = realpath(__DIR__ . '/../../fixture/compile/' . $name . '/source') + ?: throw new RuntimeException(sprintf('Fixture `%s` missing', $name)); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + $out = file_get_contents($this->targetDir . '/Use.php'); + self::assertIsString($out, sprintf('compile produced no Use.php for fixture %s', $name)); + return $out; + } + + /** + * @return list + */ + private function execCompiled(string $compiledFile, string $printer): array + { + $runScript = $this->workDir . '/run.php'; + file_put_contents($runScript, sprintf( + "&1', $output, $exit); + self::assertSame(0, $exit, "Run failed:\n" . implode("\n", $output)); + return $output; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/fixture/compile/closure_dispatcher_arrow/source/Use.xphp b/test/fixture/compile/closure_dispatcher_arrow/source/Use.xphp new file mode 100644 index 0000000..556e8ab --- /dev/null +++ b/test/fixture/compile/closure_dispatcher_arrow/source/Use.xphp @@ -0,0 +1,19 @@ +(T $x): T => $x + $y; +$y = 2; +$resultArrow = $id::(42); // expect 43 (uses captured $y = 1). diff --git a/test/fixture/compile/closure_dispatcher_defaults/source/Use.xphp b/test/fixture/compile/closure_dispatcher_defaults/source/Use.xphp new file mode 100644 index 0000000..b3ec079 --- /dev/null +++ b/test/fixture/compile/closure_dispatcher_defaults/source/Use.xphp @@ -0,0 +1,25 @@ +()` routes correctly to the all-defaults specialization. +// +// Composes with `use (...)` (P5.6): defaults + captures work together. +// The single specialization shared by `$g::<>(...)` calls dedupes via +// the padded-args tag. +$prefix = '#'; + +$f = function(T $x) use ($prefix): string { + return $prefix . $x; +}; +$g = fn(T $x): T => $x; + +$resultPaddedClosure = $f::<>(42); // pads T -> int. +$resultExplicitClosure = $f::('hi'); // explicit T = string. +$resultPaddedArrow = $g::<>('world'); // pads T -> string. diff --git a/test/fixture/compile/closure_dispatcher_use_clause/source/Use.xphp b/test/fixture/compile/closure_dispatcher_use_clause/source/Use.xphp new file mode 100644 index 0000000..a40d397 --- /dev/null +++ b/test/fixture/compile/closure_dispatcher_use_clause/source/Use.xphp @@ -0,0 +1,23 @@ +uses` verbatim to its +// own `use` clause; each capture is lifted as a trailing `mixed` (or +// `mixed &`) param on every specialized function. The named-after- +// unpack match-arm args preserve ref-ness across the call boundary, +// so `$counter` mutations inside the body write through to the outer +// scope. +$base = 10; +$counter = 0; +$f = function(T $x) use ($base, &$counter): array { + $counter++; + return [$x, $base, $counter]; +}; + +$callA = $f::(1); // [1, 10, 1] -- $counter mutated to 1 in outer scope. +$callB = $f::(2); // [2, 10, 2] -- $counter mutated to 2 in outer scope. +$callC = $f::('hi'); // ['hi', 10, 3] -- new specialization, same captures. diff --git a/test/fixture/compile/closure_generic/source/Use.xphp b/test/fixture/compile/closure_generic/source/Use.xphp index 9a5acd4..2376ba1 100644 --- a/test/fixture/compile/closure_generic/source/Use.xphp +++ b/test/fixture/compile/closure_generic/source/Use.xphp @@ -4,12 +4,15 @@ declare(strict_types=1); namespace App\ClosureGeneric; -// Capture-free generic closure -- hoists to a top-level Function_ at -// specialization time. The variable assignment site stays as-is; the call -// site `$pair::<...>(...)` rewrites to the hoisted function call. +// Capture-free generic closure -- the P5.4 ClosureDispatcher rewrites +// the Assign's RHS to a sentinel-arg-prefix dispatcher closure that +// routes each call via a `match` on a `T_` tag. Specializations +// land as top-level `closure_pair_T_` functions in the same +// namespace. Call sites get the tag prepended as the first argument; +// duplicate arg-tuples dedupe into a single match arm. $pair = function(K $key, V $value): array { return [$key, $value]; }; $pair::('age', 42); -$pair::('count', 7); // same specialization -- de-duped via alreadyGenerated. +$pair::('count', 7); // same specialization -- de-duped via the tag set. From 04b2239af9ce333735d0ef893d24c3ab30b74258 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 13:54:57 +0000 Subject: [PATCH 28/36] build(make): remove infection memory limit so dev runs don't OOM `test/mutation` was OOM-ing under PHP's default `memory_limit` while running the full Infection sweep -- the per-mutant test process can peak above the default cap once enough source files are mutated in parallel via `--threads=max`. Setting `-d memory_limit=-1` removes the cap for the Infection driver so local mutation runs complete end-to-end. CI has its own memory ceiling and is exempted; this change targets the dev-env run. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6f222c6..b15499e 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test/unit: # the test that kills it is being written. Raise to 100% once the # repo is stable enough that no new test gaps are expected. test/mutation: - php vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95 + php -d memory_limit=-1 vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95 # Humbug Box is the standard tool for compiling a Composer-managed # PHP project into a single self-contained PHAR. Pinned to a known- From 2157a481fb4ed4cd28a265f1edb6a7f69252cae8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 6 Jun 2026 16:16:08 +0000 Subject: [PATCH 29/36] docs(public): refresh docs/ tree with syntax tour, caveats, errors The pre-refresh docs/ was a mix of a 438-line compiler deep-dive, a stale roadmap listing already-shipped features as "next", and a single sprawling generics reference page. Restructure into a layered tree that lets a new PHP developer go from install to specific syntax feature in a few clicks, with all rejections collected in one place. New layout: docs/index.md -- landing + nav + heads-up banner docs/getting-started.md -- install + first compiled generic class docs/syntax/ -- 9 syntax pages, one per feature, all following the same Example / What gets emitted / Rules / Caveats / See also template docs/caveats.md -- centralized "what's not supported" page with x/y/why structure per item docs/errors.md -- searchable reference of every compile- time error string with section pointers docs/guides/ -- how-it-works (the old compiler.md, with pipeline phases renamed to stages), runtime-semantics (extracted), and a refreshed comparison vs RFC/TS/Kotlin/Rust docs/roadmap.md -- shipped section expanded, next slimmed, long-term renamed to discovery; Mermaid timeline simplified so it actually renders, with plain-Markdown detail below as the source of truth Test fixture comments under closure_generic/ and the three closure_dispatcher_* fixtures get cleaned of internal sprint identifiers so the fixtures referenced from the new syntax pages read as user-facing examples rather than implementation notes. Subsequent edits in this commit address an expert review pass calibrated to the actual readership (PHP internals, the RFC author, phpstan/psalm authors). The substantive changes: - comparison.md gains an RFC column and is reframed against bound-erasure as the runtime-semantics dividing line, with cell- level corrections for the cross-language claims (TS unknown is not a wildcard; Kotlin where-clauses give intersection only, not DNF; Rust variance is inferred, not declared via PhantomData) - runtime-semantics.md renames "marker interfaces as existential types" to "wildcard-shaped positions" -- the marker carries no runtime witness for T, so calling it an existential is too strong; the Kotlin Box<*> and Java Box analogies are now correctly bounded with that caveat - type-bounds.md error-message example is replaced with the verbatim string Registry::checkBounds actually emits - caveats.md self-contradicting reflection example ("returns 2, not the original 2") is replaced with a 3-arg closure that actually demonstrates the point - errors.md F-bounded recursion message replaces the unparseable placeholder form Box<> with the literal Box - variance.md drops the misleading "tighter than the RFC text" framing (the RFC doesn't define variance) and reframes the strict- invariance rule as a consequence of emitting real extends edges - how-it-works.md adds a one-paragraph reconciliation between the six narrative stages and the source's finer-grained Phase 0..5 labels - README PSR-4 setup gets a clarifier about which paths in the App\\ array mapping carry weight and which are dead weight, plus a note that dump-autoload only needs to run once after editing composer.json - README drops the Wikipedia link on monomorphization in favor of a one-clause inline definition - README is condensed from a 5-step Getting started walkthrough to a ~50-line Quick start, with the full walkthrough now living exclusively under docs/getting-started.md; the See also section is reorganized to surface the docs in reading order - docs/index.md tightens the "zero awareness" framing - Anchor names propagate after the existential -> wildcard-shaped rename so cross-doc links keep resolving - The wildcard story gets a Discovery roadmap entry (under "Type system breadth") signalling the work that would lift the marker from a wildcard-shaped position to a real existential - getting-started.md is rewritten to match the README's canonical PSR-4 flow rather than telling a parallel (and previously wrong) clone-and-handroll-spl-autoload story - Three broken links from the README pointing at the deleted docs/type-system/ tree are fixed Verification: every cross-link inside docs/ and README.md resolves; every fixture reference under test/fixture/compile/ points at a real directory; 422-test suite continues to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 142 +---- docs/caveats.md | 495 ++++++++++++++++++ docs/errors.md | 147 ++++++ docs/getting-started.md | 161 ++++++ docs/guides/comparison.md | 223 ++++++++ docs/{compiler.md => guides/how-it-works.md} | 82 +-- docs/guides/runtime-semantics.md | 203 +++++++ docs/index.md | 61 +++ docs/roadmap.md | 305 +++++++++-- docs/syntax/array-sugar.md | 74 +++ docs/syntax/classes-and-interfaces.md | 85 +++ docs/syntax/closures-and-arrows.md | 108 ++++ docs/syntax/defaults.md | 96 ++++ docs/syntax/index.md | 79 +++ docs/syntax/methods-and-functions.md | 96 ++++ docs/syntax/pseudo-types.md | 103 ++++ docs/syntax/turbofish.md | 105 ++++ docs/syntax/type-bounds.md | 106 ++++ docs/syntax/variance.md | 126 +++++ docs/type-system/comparison.md | 307 ----------- docs/type-system/generics/index.md | 271 ---------- .../closure_dispatcher_arrow/source/Use.xphp | 15 +- .../source/Use.xphp | 15 +- .../source/Use.xphp | 12 +- .../compile/closure_generic/source/Use.xphp | 11 +- 25 files changed, 2614 insertions(+), 814 deletions(-) create mode 100644 docs/caveats.md create mode 100644 docs/errors.md create mode 100644 docs/getting-started.md create mode 100644 docs/guides/comparison.md rename docs/{compiler.md => guides/how-it-works.md} (83%) create mode 100644 docs/guides/runtime-semantics.md create mode 100644 docs/index.md create mode 100644 docs/syntax/array-sugar.md create mode 100644 docs/syntax/classes-and-interfaces.md create mode 100644 docs/syntax/closures-and-arrows.md create mode 100644 docs/syntax/defaults.md create mode 100644 docs/syntax/index.md create mode 100644 docs/syntax/methods-and-functions.md create mode 100644 docs/syntax/pseudo-types.md create mode 100644 docs/syntax/turbofish.md create mode 100644 docs/syntax/type-bounds.md create mode 100644 docs/syntax/variance.md delete mode 100644 docs/type-system/comparison.md delete mode 100644 docs/type-system/generics/index.md diff --git a/README.md b/README.md index e95dcd2..d7cc3bb 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ ## What it is `xphp` is a superset of `php` that gives developers real generics, -powered by [monomorphization](https://en.wikipedia.org/wiki/Monomorphization) at -compile time. +powered by monomorphization at compile time -- one specialized class +per concrete instantiation, no runtime dispatch overhead. In a more inspirational mood, it is a fast lane for the `php` language, a bridge between what developers need today and what `php` will support in the future. @@ -28,9 +28,9 @@ between what developers need today and what `php` will support in the future. Generics specialize into concrete classes with native typehints the engine enforces, so the safety is real and the abstraction compiles away to nothing. -The compiler turns `xphp` into regular `php`. In the end it's good ~~old~~ -modern `php`, but developers and AI agents have richer abstractions to design -better solutions. +The compiler turns `xphp` into regular `php`. The runtime sees ordinary +classes; richer abstractions live entirely in the source you write and +at build time. ## Ecosystem and community first @@ -90,143 +90,59 @@ type-system additions. The remaining features are on the [roadmap](docs/roadmap.md): type aliases, literal types, mapped and conditional types to name a few. -## Getting started - -### 1. Install the xphp package +## Quick start ```bash composer require --dev xphp-lang/xphp ``` -### 2. Enhance your PSR-4 autoload config - -Add a PSR-4 entry to `composer.json` so the standard autoloader finds the -specialized classes without manual `require`. +Add the autoload mapping to your `composer.json` and run +`composer dump-autoload`: -```json5 +```json { "autoload": { "psr-4": { - // generics will be converted into specialized classes, - // they need to have their own namespace. - "XPHP\\Generated\\": "/Generated/", - "App\\": [ - // path to your normal/regular php code - "", - // some `xphp` files just need to be rewritten into native php to use specialized classes, - // but their namespace will remain the same. - "" - ], + "XPHP\\Generated\\": ".xphp-cache/Generated/", + "App\\": ["src", "dist"] } } } ``` -After that, update your autoload file via `composer dump-autoload`. - -### 3. Write `xphp` code - -You can define a generic class and instantiate it exactly as you would expect: +Write a generic class and use it: ```php -// /Collection.xphp +// src/Collection.xphp namespace App; class Collection { - private T[] $items; - - // The generic 'T' is used directly in the constructor signature - public function __construct(T ...$items) { - $this->items = $items; - } - - public function first(): ?T { - return $this->items[0] ?? null; - } + public function __construct(public T ...$items) {} + public function first(): ?T { return $this->items[0] ?? null; } } -// /main.xphp +// src/Use.xphp namespace App; -$users = new Collection::( - new User('Alice'), - new User('Bob') -); +$users = new Collection::(new User('Alice'), new User('Bob')); +echo $users->first()->name; ``` -### 4. Compile +Compile: ```bash -vendor/bin/xphp compile -``` - -| Argument | Required | Default | Purpose | -|------------|----------|---------------|--------------------------------------------------------------------------------------| -| `` | yes | -- | Directory of `.xphp` files (PSR-4 layout) | -| `` | no | `dist` | Where rewritten `.php` files land -- your user code with generic call sites replaced | -| `` | no | `.xphp-cache` | Where specialized classes live | - -p.s. you can `gitignore` files in `` and `` as they can be -generated in your CI/CD pipeline. - -#### Sample output - -The sample below uses a readable name (`Collection_User`) for clarity. The -compiler actually emits hashed FQNs of the form -`\XPHP\Generated\App\Collection\T_` -- see -the [generics reference](docs/type-system/generics/index.md) for the real -scheme. - -```php -// /Generated/Collection_User.php -namespace XPHP\Generated; - -use App\User; - -class Collection_User { - private array $items; - - // The generic 'T' is replaced natively with the concrete 'User' type - public function __construct(User ...$items) { - $this->items = $items; - } - - public function first(): ?User { - return $this->items[0] ?? null; - } -} - -// /main.php -namespace App; - -use XPHP\Generated\Collection_User; - -// The generic instantiation is mapped directly to the generated class -$users = new Collection_User( - new User('Alice'), - new User('Bob') -); +vendor/bin/xphp compile src dist .xphp-cache ``` -### 5. Deploy - -The compiler monomorphizes the generic classes and converts downstream code -into native `php` code. - -Meaning every place where generics are declared or used is converted into normal -`php` code. No impact on the runtime. You still deploy `php` code. - -### Project structure - -``` -/ -├── # php/xphp source files (PSR-4: namespace mirrors directory structure) -├── # rewritten .php (gitignored, generated) -├── # specialized classes (gitignored, generated) -└── composer.json # PSR-4: XPHP\Generated\ => /Generated/ -``` +That's the whole loop: install, set up autoload, write `.xphp`, +compile. `dist/` holds your rewritten code; `.xphp-cache/Generated/` +holds the specialized classes. Both can be gitignored and rebuilt +in CI. ## See also -- [Type-system comparison](docs/type-system/comparison.md) -- [Full generics reference](docs/type-system/generics/index.md) +- [Getting started](docs/getting-started.md) -- full walkthrough including PSR-4 details, runtime semantics, and what the generated PHP looks like +- [Syntax tour](docs/syntax/index.md) +- [Caveats](docs/caveats.md) +- [Type-system comparison](docs/guides/comparison.md) +- [Roadmap](docs/roadmap.md) diff --git a/docs/caveats.md b/docs/caveats.md new file mode 100644 index 0000000..ce37261 --- /dev/null +++ b/docs/caveats.md @@ -0,0 +1,495 @@ +# Caveats + +Every shipped feature in xphp has trade-offs. This page collects the +ones you'll hit in real code, in the order they're likely to bite, +each with the underlying reason and the workaround. + +Pages in the [syntax tour](syntax/) link back to specific sections +here using anchor links — search this page for the same heading text. + +## `$this`-capturing arrows and closures rejected + +### ❌ What doesn't work + +```php +class Holder { + public int $v = 5; + public function go(): int { + $f = fn(T $x): int => $x + $this->v; + return $f::(2); + } +} +``` + +``` +Generic arrow `$f::<...>(...)` captures `$this`, which is not yet +supported. Rewrite as a method on the enclosing class, or extract +the value of $this->property into a local variable before the arrow. +``` + +The same error fires for generic closures whose body references +`$this`. + +### Why + +The closure variable rewrite produces a dispatcher closure that +forwards to top-level specialized functions. Top-level functions +can't see the enclosing class's `$this`, and PHP doesn't allow +`use ($this)` on a closure either. Carrying `$this` would need +either a method-rewriting pass (today's level) or rewriting `$this->v` +to a lifted `mixed $__xphp_this` param everywhere — a deeper +refactor that's queued up for later. + +### ✅ Workaround + +Either rewrite as a method on the class: + +```php +class Holder { + public int $v = 5; + public function go(): int { + return $this->add::(2); + } + public function add(T $x): int { + return $x + $this->v; + } +} +``` + +Or extract the value to a local before the closure: + +```php +class Holder { + public int $v = 5; + public function go(): int { + $base = $this->v; + $f = fn(T $x): int => $x + $base; + return $f::(2); + } +} +``` + +--- + +## `static` closures not supported + +### ❌ What doesn't work + +```php +$f = static function(T $x): T { return $x; }; +$f::(42); +``` + +``` +Generic static closures cannot yet be specialized at call sites. +Rewrite the call site for `$f::<...>(...)` to use a named generic +function at file scope. +``` + +The same form is rejected at parse time when combined with defaults: + +```php +$f = static function(T $x): T { return $x; }; +``` + +``` +Generic parameter `T` has a default value, which is not yet supported +on static closures. Drop the `static` modifier or assign the closure +to a named function. +``` + +### Why + +The dispatcher closure that xphp emits to route specialized calls +needs a `$this`-binding target for one of the planned future +extensions. `static` closures explicitly block `$this` binding, +which removes that hook. + +### ✅ Workaround + +Drop the `static` modifier, or lift the body to a named function: + +```php +$f = function(T $x): T { return $x; }; // works +// or +function id(T $x): T { return $x; } +id::(42); // works +``` + +--- + +## Variance markers are class-level only + +### ❌ What doesn't work + +```php +function process<+T>(T $x): T { /* ... */ } // free function +class Box { + public function map<+U>(callable $f): Box { /* ... */ } // method +} +$producer = function<+T>(): T { /* ... */ }; // closure +$arrow = fn<+T>(T $x): T => $x; // arrow +``` + +``` +Variance markers `+T` / `-T` are not yet supported on methods, +functions, closures, or arrow functions; move the generic to a +class-level type parameter. +``` + +### Why + +Variance turns into real `extends` chains between specialized classes +(see [variance](syntax/variance.md)). Methods, functions, closures, +and arrows don't have a stable identity to anchor an `extends` chain +to — their specializations are functions, not classes, so there's +nothing for the subtype edge to attach to. + +### ✅ Workaround + +Put the template on a named class and use its method: + +```php +class Producer<+T> { + public function __invoke(): T { /* ... */ } +} +``` + +--- + +## Reflection on rewritten generic closures + +### ❌ What doesn't work + +```php +$triple = function(A $a, B $b, C $c): array { return [$a, $b, $c]; }; +$triple::(1, 'two', true); + +// After compile: +$ref = new ReflectionFunction($triple); +$ref->getNumberOfParameters(); // returns 2 -- the dispatcher's + // (string $tag, mixed ...$args) shape, + // NOT the original three. +$ref->getParameters()[0]->getName(); // returns '__xphp_tag' +``` + +Closure serializers (`opis/closure`, +`laravel/serializable-closure`) serialize the dispatcher closure +rather than the original body. + +### Why + +The variable holding the generic closure is rewritten to a +**dispatcher** closure with a fixed `(string $__xphp_tag, mixed ...$__xphp_args)` +signature. The real body lives in one or more top-level functions +keyed by the tag. Reflection only sees the dispatcher. + +Pre-this-machinery, generic closures couldn't be specialized at all +(they were rejected outright), so reflection-based serializers +couldn't see anything. The 2-arg dispatcher shape is an improvement +over a hard reject, just not a transparent one. + +### ✅ Workaround + +If your code path serializes closures via reflection, use a named +generic function instead: + +```php +function pair(K $k, V $v): array { return [$k, $v]; } + +// Now reflection sees the real `pair_T_` specialization: +$ref = new ReflectionFunction('App\\pair_T_'); +$ref->getNumberOfParameters(); // 2 (the real K, V) +``` + +If you need the variable form for some other reason (currying, etc.), +serialize the captured state separately and reconstruct the closure +on the receiving side. + +--- + +## Reified-T is an xphp-specific divergence + +### ❌ What doesn't port + +```php +function decode(string $json): T { + $data = json_decode($json, true); + return new T(...$data); // works in xphp +} + +if ($x instanceof T) { /* ... */ } // works in xphp +$class = T::class; // works in xphp +``` + +The same code under a future RFC-aligned (erasure-based) PHP runtime +would NOT work — `T` would be erased at runtime, so `new T(...)`, +`instanceof T`, and `T::class` all become meaningless. + +### Why + +xphp uses monomorphization: every `Box` is a real class with +`int` baked into every signature. Inside the specialized body, `T` +literally becomes `int`, so reified-T operations Just Work. + +The RFC's erasure model doesn't keep T at runtime, so any code +relying on T being a runtime value would break on the future native +PHP runtime. + +### ✅ Workaround + +If forward-portability to a future RFC-aligned runtime matters more +than reified-T ergonomics, avoid `instanceof T`, `T::class`, and +`new T(...)`. Pass class names as explicit arguments instead: + +```php +function decode(string $json, string $class): mixed { + $data = json_decode($json, true); + return new $class(...$data); +} +$user = decode($payload, User::class); +``` + +If forward-portability isn't a concern (you're not planning to +target a future erased-T runtime), use reified-T freely — it's one +of the things monomorphization makes naturally easy. + +The README's "Heads up" banner mentions this divergence too. + +--- + +## Branching narrowing precision loss + +### ❌ What's less precise than ideal + +```php +$x = new Foo(); +if ($cond) { + $x = new Bar(); +} +$x->m::($arg); // de-specializes -- not a Foo or Bar method call +``` + +The post-branch call drops to a non-specialized path because the +analysis can't prove a single class for `$x`. + +> This is a **precision** issue, not a soundness one. xphp will NOT +> pick the wrong class — it just gives up on the specialization. + +### Why + +Receiver-type analysis is conservative: when `$x` is reassigned +inside a branch and the arms don't agree on a class, post-branch +calls fall back to a non-specialized path. Otherwise the compiler +could pick a class that doesn't match what the variable actually +holds at runtime. + +The same-arms-agree shape IS supported: + +```php +$x = new Foo(); +if ($cond) { + $x = new Foo(); // same class as the other arm +} +$x->m::($arg); // specializes against Foo +``` + +### ✅ Workaround + +Either separate the call sites by branch: + +```php +if ($cond) { + $x = new Bar(); + $x->m::($arg); // specializes against Bar +} else { + $x = new Foo(); + $x->m::($arg); // specializes against Foo +} +``` + +Or use a typed local before the call: + +```php +/** @var Foo|Bar $x */ +$x = $cond ? new Bar() : new Foo(); +// Manually call the right specialized name on each branch. +``` + +A future version may add union-type tracking with runtime dispatch, +but it's queued behind higher-priority work. + +--- + +## Variance validator and trait `use` + +### ❌ What doesn't get checked + +```php +trait HasItem { + public function set(T $item): void { /* ... */ } +} + +class Container<+T> { // covariant + use HasItem; + // The `set(T $item)` from the trait places T in a contravariant + // position. The validator should reject -- but it doesn't, because + // it doesn't walk into trait-imported methods. +} +``` + +The variance validator emits no error today on trait-imported method +signatures. If the trait's method places T in a forbidden position, +the violation slips through compilation. + +### Why + +The validator walks `ClassMethod` nodes declared directly on the +ClassLike. Trait method bodies are stitched into the class by Zend +at compile time; the AST we walk pre-stitching doesn't see them. + +### ✅ Workaround + +Manually audit traits used by variant generic classes. If you control +the trait, copy the body into the class directly so the validator +can see it. + +--- + +## `T[]` is xphp-only + +### ❌ What doesn't work + +```php +class Map { + private array $items; // rejected +} +``` + +The +[PHP RFC for generics](https://wiki.php.net/rfc/bound_erased_generic_types) +explicitly bans the `array` syntax, and xphp follows. + +### Why + +The RFC took a clear position against expanding `array` into a +generic. xphp matches that boundary so source written today is +forward-compatible. + +### ✅ Workaround + +Use the `T[]` sugar (xphp-only but innocuous) or write `array` +directly: + +```php +class Map { + private array $items; // works, no compile-time element check +} +``` + +If you need a typed key/value container, build it from a generic +class: + +```php +class Map { + private array $items = []; + public function set(K $k, V $v): void { $this->items[$k] = $v; } + public function get(K $k): V { return $this->items[$k]; } +} +``` + +--- + +## Duplicate generic template declaration + +### ❌ What doesn't work + +Two `.xphp` files in the same source set both declare a generic +function (or class) with the same FQN: + +``` +Generic function template "App\identity" already declared (in +src/Util.xphp); duplicate declaration in src/Other.xphp. +``` + +(The same error fires for classes — the message uses +`Generic template` instead of `Generic function template`.) + +### Why + +Specialization keys every template by FQN. Two definitions with the +same name would collide on the generated specializations and silently +overwrite each other; xphp surfaces both source paths instead so you +can see what's clashing. + +### ✅ Workaround + +Pick one canonical location. If you genuinely need the same name in +two namespaces, put them in different namespaces — the FQN differs +and they won't collide. + +--- + +## Invalid default expression shape + +### ❌ What doesn't work + +```php +class Bad {} // union not allowed +class Bad {} // nullable not allowed +class Bad {} // intersection not allowed +``` + +``` +Generic parameter `T` has an invalid default; only a single concrete +or generic type is allowed after `=` (no nullable or union shapes). +``` + +### Why + +Defaults are substituted at instantiation time. Supporting union / +intersection / nullable defaults would require a runtime decision on +which arm to materialize, which isn't compatible with +monomorphization's "one specialization per concrete arg tuple" model. + +### ✅ Workaround + +Pick a single default and let users override it explicitly with +turbofish: + +```php +class Container {} +// users can still write: new Container:: or new Container:: +``` + +--- + +## Anonymous classes can't be generic + +### ❌ What doesn't work + +```php +$x = new class { + public T $item; +}; +``` + +Both the RFC and xphp forbid this — `new class { ... }` is a +parse error. + +### Why + +Anonymous classes have no name to carry through specialization. The +template-to-specialization machinery is keyed on the FQN; anonymous +classes don't have one. + +### ✅ Workaround + +Lift to a named class: + +```php +class TempContainer { + public T $item; +} +$x = new TempContainer::(); +``` diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..1e14d3c --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,147 @@ +# Errors and diagnostics + +Every compile-time rejection from xphp lists the error message +verbatim below, paired with the docs section that explains the +constraint. Search this page for the text your compile output shows. + +## Quick index + +| If the message contains... | Read | +|----------------------------|------| +| `captures \`$this\`` | [Caveats — `$this`-capturing arrows and closures](caveats.md#this-capturing-arrows-and-closures-rejected) | +| `static closures cannot yet be specialized` | [Caveats — `static` closures not supported](caveats.md#static-closures-not-supported) | +| `Variance markers \`+T\` / \`-T\` are not yet supported` | [Caveats — variance markers are class-level only](caveats.md#variance-markers-are-class-level-only) | +| `Variance violation in template` | [Variance](syntax/variance.md) | +| `Generic bound violated` | [Type bounds](syntax/type-bounds.md) | +| `Default for generic parameter \`...\` violates the parameter's bound` | [Type bounds](syntax/type-bounds.md) + [Defaults](syntax/defaults.md) | +| `has no default but follows a parameter with a default` | [Defaults — required-after-default rule](syntax/defaults.md) | +| `has an invalid default; only a single concrete or generic type is allowed` | [Caveats — invalid default expression shape](caveats.md#invalid-default-expression-shape) | +| `cannot use itself as a bound` | [Type bounds — F-bounded](syntax/type-bounds.md) | +| `already declared` ... `duplicate declaration` | [Caveats — duplicate generic template declaration](caveats.md#duplicate-generic-template-declaration) | +| `was instantiated but never defined` | The template was used but no source file declared it — typo or missing import | +| `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults | +| `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. | +| `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l .xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. | + +## Full error texts (verbatim) + +For grep-from-output workflows, here are the message strings exactly +as the compiler emits them. + +### `$this`-capturing arrows / closures + +``` +Generic `$::<...>(...)` captures `$this`, which +is not yet supported. Rewrite as a method on the enclosing class, or +extract the value of $this->property into a local variable before +the . +``` + +### `static` closures + +``` +Generic static closures cannot yet be specialized at call sites. +Rewrite the call site for `$::<...>(...)` to use a named +generic function at file scope. +``` + +### Variance markers on non-class templates + +``` +Variance markers `+T` / `-T` are not yet supported on methods, +functions, closures, or arrow functions; move the generic to a +class-level type parameter. +``` + +### Variance composition violation + +``` +Variance violation in template