diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ec849..10c2a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to `xphp` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Method-generic turbofish grounded by an enclosing type parameter.** A turbofish + whose type argument is supplied by the enclosing generic scope now grounds **per + specialization** and runs, instead of being rejected by the emitted-marker backstop: + a named free-function forward (`identity::($v)` inside `wrap`), a static call + (`self::gen::()`, `Maker::wrap::()` inside `Box` — the idiomatic "delegate + to a shared static generic helper" shape), and an instance call (`$this->dup::()`, + `$m->dup::()` on a non-generic receiver, including a target declared on a generic + base class). Freshly specialized bodies are re-grounded transitively, so multi-hop + forwards and mutually recursive generics converge; a member grounded onto the + program's classes is deduplicated per unique argument tuple, and an instantiation + that first appears inside a grounded body is discovered like any other. A bound that + only becomes provable after specialization (`gen` receiving the + class's `T`) is checked per instantiation, and a strictly-growing forward chain + (`grow` calling `grow::>`) is rejected as non-convergent + (`xphp.unconverged_method_specialization`) instead of specializing forever. See + [turbofish](docs/syntax/turbofish.md) and the + [remaining caveats](docs/caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter) + (closure turbofish in generic function bodies, cross-template targets, and the + late-bound `static::`/`parent::` spellings still fail loudly). + +### Fixed + +- **`xphp check` / `xphp compile` parity on enclosing-parameter turbofish.** `check` + previously reported nothing for the shapes only the compile-time emitted-marker + backstop rejected — a pipeline gating on `check` alone saw green on code `compile` + refused. The validate-only pass now grounds each specialization the same way + `compile` does and collects the same diagnostics (a violated grounded bound, a + non-convergent chain, a surviving marker), located at the template's real source + line. + ## [0.3.0] ### Added diff --git a/docs/caveats.md b/docs/caveats.md index e3cd7b4..b565be6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -762,32 +762,53 @@ so the growing type is never reached through an unbounded chain. ## Generic turbofish grounded by an enclosing type parameter A turbofish whose type argument is supplied by an **enclosing** generic scope — a -function type parameter or a class type parameter — cannot yet be specialized. Every -such shape is rejected with a loud compile error rather than emitted as runtime-fatal -code; the representative cases below are not exhaustive (a named free-function forward -grounded by an enclosing parameter, `return identity::($v)` inside `wrap`, is the -same class of shape and rejected the same way). Each may be lifted in a future version. +function type parameter or a class type parameter — is grounded **per specialization**: +the call is abstract inside the template, and once the enclosing generic specializes +(`wrap::`, `new Box::`) the now-concrete call is dispatched to a real +specialized member or function. The shapes below compile and run: -### ❌ What doesn't work +```php +function identity(U $x): U { return $x; } +function wrap(T $v): T { return identity::($v); } // ✅ named forward +wrap::(3); -A generic **closure** grounded by an enclosing function type parameter: +final class Maker +{ + public static function wrap(X $v): array { return [$v]; } +} -```php -function relay(S $v): S +class Box { - $inner = fn(I $x): I => $x; - return $inner::($v); // ❌ `S` is not concrete here + public function make(T $v): T { return self::gen::($v); } // ✅ own static + public function viaMaker(T $v): array { return Maker::wrap::($v); } // ✅ external static + public function twice(T $v): array { return $this->dup::($v); } // ✅ own instance + public static function gen(U $x): U { return $x; } + public function dup(V $x): array { return [$x, $x]; } } ``` -``` -Generic closure call `$inner::(...)` cannot be specialized: its type argument(s) -are grounded only by an enclosing generic scope and are not concrete here … -``` +Instance calls also ground on a receiver with a **non-generic** declared type +(`$maker->wrap::($v)` for a `Maker $maker` parameter), and both call shapes ground +a target declared on a generic **base** class (`$this->dup::` / `self::gen::` +where the target lives on `Base` — the member lands on the calling class's +specialization). Both `xphp check` and `xphp compile` +agree on every accept and reject below: a bound that only becomes provable after +specialization (`gen` called with the class's `T`) is checked per +instantiation in both modes. + +### ❌ What still doesn't work -A **concrete** inner closure turbofish, but written **inside a generic function body**: +A generic **closure** grounded by an enclosing function type parameter — and a +**concrete** inner closure turbofish written inside a generic function body. Closure +dispatch is not re-entered per specialization: ```php +function relay(S $v): S +{ + $inner = fn(I $x): I => $x; + return $inner::($v); // ❌ xphp.unspecialized_generic_closure +} + function outer(T $seed): int { $f = fn(U $x): U => $x; @@ -795,55 +816,47 @@ function outer(T $seed): int } ``` -A **method/static turbofish grounded by an enclosing class type parameter**: +A target declared on a **different generic template** — its specialized member belongs +on that template's own specializations, which the grounding pass must not touch: ```php -class Box +class Other { public static function gen(U $x): U { return $x; } } +class Holder { - public function make(T $v): T { return self::gen::($v); } // ❌ `T` from the class - public static function gen(U $x): U { return $x; } + public function m(T $v): T { return Other::gen::($v); } // ❌ cross-template } ``` -``` -A generic turbofish/closure marker survived specialization into the emitted output … -[xphp.unspecialized_generic_leak] -``` +The late-bound `static::` / `parent::` spellings (resolving them statically could +silently re-route a subclass or parent dispatch — rejecting loudly is the contract), +a forward to a **bare top-level** (namespace-less) generic function from inside a +generic class, and a **method-level** parameter forwarded to any generic method +(`$this->dup::` inside `probe`). A method-level parameter can't be forwarded +because a generic method is specialized before its class, so `W` has no concrete +value where the forward would be grounded — a non-erasable target reports +`xphp.unspecializable_self_call`, an erasable one `xphp.unspecialized_generic_leak`, +but neither is supported. -### Why +A **strictly-growing** forward chain is rejected as non-convergent rather than +compiled forever: -Variable-turbofish and method-turbofish dispatch is **call-site-driven**: a site is -specialized only when its type arguments are concrete *at that site*. When the argument -comes from an enclosing type parameter it is still abstract when the inner site is -visited, so no concrete dispatch can be built; and the concrete-inner case (`outer`) -only fails because the closure sits inside a *generic function* body, which the current -dispatch pass does not re-enter per specialization. Left un-grounded, each would emit -PHP that names a non-existent type-parameter class (`App\I`, `App\U`, or a stripped -`gen()` method) and fatal on first use. Rather than emit that, xphp fails the build: the -closure form is caught at the source seam in both `xphp check` and `xphp compile` -(`xphp.unspecialized_generic_closure`); the two shapes that reach code generation are -caught by a compile-time backstop over the emitted output -(`xphp.unspecialized_generic_leak`). Grounding these shapes so they *run* is tracked -for a later release; today the guarantee is only that they never miscompile silently. - -**`xphp check` catches only the closure form.** The two shapes that surface at code -generation (`outer`, `Box::make`, and the named free-function forward above) are caught -by the emit-time backstop, which `xphp check` does not run — it validates without -emitting. So `check` reports **zero** diagnostics for those, while `compile` rejects -them loudly. A CI pipeline that gates on `xphp compile` (or runs it after `check`) is -fully covered; one that gates on `xphp check` alone will see green on code that -`compile` will reject. This is a completeness gap in `check`, never a runtime-safety -hole: no fatal-able code is ever emitted. +```php +function grow(T $v): int +{ + return grow::>(new Box::($v)); // ❌ xphp.unconverged_method_specialization +} +``` -### ✅ Workaround +Every rejected shape fails **loudly** — with the diagnostic named above or the +`xphp.unspecialized_generic_leak` backstop — in both `check` and `compile`; none is +ever emitted as runtime-fatal PHP. + +### ✅ Workaround (for the still-rejected shapes) -Call the inner generic with an **explicit concrete** turbofish at a scope where the type -is known, or lift it out of the enclosing generic scope: +Call the inner generic with an **explicit concrete** turbofish at a scope where the +type is known, or lift it out of the enclosing generic scope: ```php $inner = fn(I $x): I => $x; echo $inner::(41); // works at file / plain-function scope - -function gen(U $x): U { return $x; } -Box::useGen(gen::(5)); // ground the generic where the type is concrete ``` diff --git a/docs/errors.md b/docs/errors.md index 2e7bd23..4230b83 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -47,11 +47,12 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but no in-scope `$var::<...>(...)` call grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable (which cannot ground it). Call it with a turbofish in the scope that declares it, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | -| `xphp.unspecialized_generic_leak` | a last-resort compile-time safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). Raised by a small set of enclosing-parameter / generic-function-scope turbofish shapes xphp cannot yet ground — a *concrete* inner closure turbofish inside a generic function body (`$f::` in `outer`), or a method/static turbofish grounded by an enclosing class type parameter (`self::gen::()` in `Box`). Call it with an explicit concrete turbofish, or move it out of the enclosing generic scope. Compile-only; the closure-grounded-by-enclosing-parameter form is caught earlier (in both `check` and `compile`) as `xphp.unspecialized_generic_closure` | +| `xphp.unspecialized_generic_leak` | a last-resort safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). The common enclosing-parameter shapes ground and run (a named free-function forward `identity::` inside `wrap`; a static/instance method turbofish grounded by the enclosing class parameter, `self::gen::()` / `$this->dup::()` / `Maker::wrap::()` in `Box`); what still reaches this backstop is a *concrete* inner closure turbofish inside a generic function body (`$f::` in `outer`), a target declared on a *different* generic template (`Other::gen::`), the late-bound `static::`/`parent::` spellings, and a forward to a bare top-level generic function from a generic class. Call the site with an explicit concrete turbofish, or move it out of the enclosing generic scope. `compile` throws; `check` collects the same diagnostic from its grounding pass. The closure-grounded-by-enclosing-parameter form is caught earlier (in both modes) as `xphp.unspecialized_generic_closure` | | `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | -| `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | +| `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a **method-level** type parameter to a non-erasable generic method (`$this->dup::()` inside `probe`): no specialization ever grounds `W`, so the call is rejected at its precise site. Forwarding the enclosing **class** parameter (`$this->dup::()` inside `Box`) is grounded per specialization and runs, as does forwarding to an *erasable* target (parameter used only as a direct input) | +| `xphp.unconverged_method_specialization` | a generic method/function forward chain mints a strictly deeper type argument every hop (`grow` calling `grow::>`), so specialization can never converge; the chain is cut off at a fixed hop depth. Break the growth by forwarding a concrete turbofish | | `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | | `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | | `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line | diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index 730dd5a..5da8b69 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -126,6 +126,20 @@ build time instead of fataling at runtime with "Call to undefined method". isn't tracked — give such a local a typed parameter/property hop, or the turbofish call fails as an undetermined receiver. +- > ⚠️ Forwarding an **enclosing class type parameter** + (`$this->dup::` / `self::gen::` / `Maker::wrap::` inside + `Box`, or `identity::` inside a generic function `wrap`) + grounds per specialization and runs — the class parameter becomes + concrete when the class specializes. Forwarding a **method-level** + parameter (`$this->dup::` inside `probe`) does **not**: a generic + method is specialized before its class, so `W` has no concrete value + where the forward would be grounded. It is a compile error regardless + of the target — `xphp.unspecializable_self_call` for a non-erasable + target, `xphp.unspecialized_generic_leak` for an erasable one. + Targets on a *different* generic template and the `static::`/`parent::` + spellings are rejected too. See + [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). + ## See also - Test fixture: `test/fixture/compile/generic_method/` diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 36f4b6b..36a5bb8 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -135,6 +135,14 @@ than a silent pass-through that fatals at runtime. error (`xphp.undetermined_receiver`), not a silent de-specialization. See [caveats](../caveats.md#branching-narrowing-precision-loss). +- > ⚠️ **Enclosing type parameters as turbofish arguments** — a turbofish + grounded by an enclosing generic scope (`identity::` inside `wrap`, + `self::gen::` / `$this->dup::` / `Maker::wrap::` inside `Box`) + is grounded per specialization and runs. Still rejected loudly: closure + turbofish inside generic function bodies, targets on a *different* + generic template, and `static::`/`parent::` spellings. See + [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). + ## See also - Test fixture: `test/fixture/compile/generic_method/` diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 3934ede..1ea37ce 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -135,8 +135,10 @@ public function compile( // Phase 2: fixed-point specialization loop. Fail-fast (an undefined template // or an exceeded depth throws) — the emit path must not proceed on a set it - // couldn't fully build. - $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false); + // couldn't fully build. The method compiler rides along: each fresh + // specialization is grounded (enclosing-param method-generic turbofish + // dispatched against the retained Phase-1a template index) before collection. + $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false, methodCompiler: $methodCompiler); // Phase 2.3: re-qualify free-function calls and const fetches in every specialization // produced by the fixed-point loop. Each body was relocated out of its origin namespace @@ -202,12 +204,12 @@ public function compile( $specializedAsts[$generatedFqn] = $first; } - // Note for future-proofing (review F9): method-level specialization runs in Phase 1a - // against the raw user-file ASTs, NOT against the specialized cache classes. That's - // safe under the current MVP limit ("generic methods on non-generic classes only" — - // see GenericMethodCompiler's docblock). If that limit ever relaxes, the specialized - // class ASTs would need to be fed back through the method compiler with their - // enclosing namespace preserved so FQN keying still works. + // Method-level specialization runs twice-shaped: Phase 1a against the raw + // user-file ASTs, then per-specialization inside the Phase-2 loop + // (GenericMethodCompiler::groundSpecializedClass, fed the retained template + // index with the spec's identity threaded — the F9 wiring note this replaces). + // Anything neither pass could ground still carries its marker and is rejected + // by the backstop below. foreach ($specializedAsts as $generatedFqn => $classAst) { // Last-resort safety net: no generic marker may survive into emitted output. A // surviving turbofish/closure marker is a site the pipeline could not ground — @@ -305,6 +307,7 @@ private function specializeToFixedPoint( RegistryCollector $collector, TypeHierarchy $hierarchy, bool $resilient, + ?GenericMethodCompiler $methodCompiler = null, ): array { /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; @@ -365,6 +368,44 @@ private function specializeToFixedPoint( } $specializedAsts[$generatedFqn] = $specialized; + + // Ground method-generic turbofish markers the class substitution just + // made concrete (`self::gen::` → `::`) BEFORE collecting: an + // own-template member appended onto the spec is then swept by the + // collect below, and externally-appended members (onto a non-generic + // user class or a function namespace — invisible to spec collection) + // are collected explicitly, so nested instantiation needs discovered by + // grounding converge through this same fixed point. + if ($methodCompiler !== null) { + if ($resilient) { + try { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: false, + ); + } catch (RuntimeException) { + // Grounding failures surface as collected diagnostics in + // check mode; a residual throw must not abort the resilient + // pass over the remaining instantiations. + $externalAppends = []; + } + } else { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: true, + ); + } + if ($externalAppends !== []) { + $collector->collect($externalAppends, ""); + } + } + $collector->collect([$specialized], ""); } @@ -381,6 +422,10 @@ private function specializeToFixedPoint( } if ($countAfter === $countBefore) { + // @infection-ignore-all Continue_ -- break vs continue reconverges: an + // unchanged count means this pass recorded no new instantiations, so the + // next iteration processes nothing new and exits via the !newlyProcessed + // break; the mutant merely skips that no-op pass. continue; } @@ -481,8 +526,12 @@ public function check(FilepathArray $sources): DiagnosticCollector // validation calls (which produce the diagnostics) run in BOTH modes; `emit` only governs // append/strip/finalize side-effects on `$astPerFile`, which is local and discarded. So // flipping it changes only wasted work, not the collected diagnostics. `emit: false` is the - // correct (no-wasted-work, no-mutation) choice. - (new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics))->process($astPerFile, emit: false); + // correct (no-wasted-work, no-mutation) choice. The instance is held: the resilient + // specialization pass below feeds each spec back through it (groundSpecializedClass) so + // enclosing-param turbofish diagnostics only provable after substitution are collected — + // keeping check's verdicts aligned with compile's. + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics); + $methodCompiler->process($astPerFile, emit: false); // Grounded closure-signature conformance. A `Closure(T $x)` target whose // type parameter is still abstract above is gradually accepted; grounding it @@ -497,7 +546,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // by-ref) mismatches were already collected by the abstract pre-loop above, so // the grounded pass skips them to avoid a duplicate report at the specialized // location. - $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true); + $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true, methodCompiler: $methodCompiler); foreach ($groundedAsts as $generatedFqn => $classAst) { $closureValidator->validateFile([$classAst], "", $diagnostics, groundedTypesOnly: true); } diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 9d25983..f55b019 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -11,7 +11,13 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use RuntimeException; /** @@ -45,39 +51,119 @@ final class GenericMarkerLeakGuard public const CODE = 'xphp.unspecialized_generic_leak'; /** - * Throw if any generic marker survives into a specialized AST subtree. + * Find the first surviving generic marker in a specialized AST subtree, or null when + * the subtree is clean. The scan is the guard's single source of truth — `assertNoLeak` + * throws on it, and `check`-mode callers degrade it to a collected diagnostic so the + * validate-only pass reports the same shapes the compile-time backstop rejects. + * + * `$includeClosureTemplates` toggles the defense-in-depth arm. The compile-time + * assert keeps it on. The check-mode drain turns it off: an un-specialized closure + * template inside a drained body always accompanies either a source-seam diagnostic + * on its call site (a different line — the template node's own line would dodge the + * caller's already-reported dedupe) or an orphan diagnostic from the + * declared-but-never-specialized check, so re-flagging the template node itself only + * double-reports; the call-site marker arm is what carries new information there. + * + * `$includeVariableTurbofish` toggles variable-turbofish FuncCalls (`$f::`). + * The check-mode CLASS-spec backstop turns it off: check's validate-only walk never + * materializes closure dispatchers, so a class-spec clone legitimately carries the + * variable marker that compile's dispatcher pass grounds — flagging it would reject + * code compile accepts. Every genuinely-broken variable-turbofish shape is caught + * elsewhere (the source seam in both modes, or the append-drain backstop, whose + * check side keeps this arm on because compile's drain rejects the same body). + * + * `$skipUnspecializedTemplates` skips the SUBTREES of function/method/closure + * declarations still carrying their generic-template marker. Same check-mode + * class-spec backstop rationale: check never strips templates, so a spec clone + * retains e.g. `a` whose body legitimately holds a `self::b::` marker — the + * template as a whole is dispatch machinery, not emitted output, and flagging its + * interior would reject code compile accepts. Compile-mode specs never contain + * such declarations, so the assert path is unaffected. * * @param Node|list $specialized the emitted specialized node(s) - * @param string $label the specialization's identity, for the error message */ - public static function assertNoLeak(Node|array $specialized, string $label): void - { + public static function findLeak( + Node|array $specialized, + bool $includeClosureTemplates = true, + bool $includeVariableTurbofish = true, + bool $skipUnspecializedTemplates = false, + ): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - $finder = new NodeFinder(); - $leak = $finder->findFirst($nodes, static function (Node $n): bool { + $matcher = static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall || $n instanceof NullsafeMethodCall ) { + if (!$includeVariableTurbofish && $n instanceof FuncCall && !$n->name instanceof Name) { + return false; + } return $n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null; } - if ($n instanceof Closure || $n instanceof ArrowFunction) { + if ($includeClosureTemplates && ($n instanceof Closure || $n instanceof ArrowFunction)) { return is_array($n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); } return false; - }); + }; - if ($leak === null) { - return; + if (!$skipUnspecializedTemplates) { + return (new NodeFinder())->findFirst($nodes, $matcher); } + // Subtree-skipping scan: NodeFinder can't prune, so walk with a traverser that + // refuses to descend into declarations still carrying the template marker. + // Preorder like findFirst, so both paths report the same first leak. + $visitor = new class($matcher) extends NodeVisitorAbstract { + public ?Node $leak = null; + + /** @param \Closure(Node): bool $matcher */ + public function __construct(private readonly \Closure $matcher) + { + } + + public function enterNode(Node $node): ?int + { + if ($this->leak !== null) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($node instanceof ClassMethod || $node instanceof Function_ + || $node instanceof Closure || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($this->matcher)($node)) { + $this->leak = $node; + // @infection-ignore-all ReturnRemoval — descending into the found + // leak's children is a no-op: the leak-set early-exit above prunes + // every subsequent node before the matcher can overwrite. The + // prune is an optimization; first-leak-wins is pinned by + // GenericMarkerLeakGuardTest's document-order test. + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($nodes); + + return $visitor->leak; + } + + /** + * Build the guard's diagnostic message for a found leak. Shared verbatim between the + * compile-time throw and the check-mode collected diagnostic so both modes name the + * same site the same way. + */ + public static function leakMessage(Node $leak, string $label): string + { // @infection-ignore-all Concat ConcatOperandRemoval — the diagnostic wording is not // behavior: the tests pin that a leak throws and that the message names the label, the // line, and the code; reordering or dropping a prose clause changes none of those. - throw new RuntimeException(sprintf( + return sprintf( 'A generic turbofish/closure marker survived specialization into the emitted output for %s ' . '(near line %d). This site could not be grounded to a concrete type, so its type-parameter ' . 'hints would reach the emitted PHP as references to non-existent classes — a runtime TypeError. ' @@ -87,6 +173,23 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi $label, $leak->getStartLine(), self::CODE, - )); + ); + } + + /** + * Throw if any generic marker survives into a specialized AST subtree. + * + * @param Node|list $specialized the emitted specialized node(s) + * @param string $label the specialization's identity, for the error message + */ + public static function assertNoLeak(Node|array $specialized, string $label): void + { + $leak = self::findLeak($specialized); + + if ($leak === null) { + return; + } + + throw new RuntimeException(self::leakMessage($leak, $label)); } } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 1cbb6ad..7d7e9da 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\While_; use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Diagnostics\Diagnostic; @@ -56,14 +57,22 @@ * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via * `ClassFqn::NAME(...)`. * - * The pass runs after the class-level pipeline has settled. It walks the per-file AST set - * (the rewritten user code AND the specialized cache classes) twice: + * The pass runs in two stages. `process()` walks the raw per-file user ASTs (Phase 1a, + * before class specialization): * 1. Collect every generic-method template — keyed by "classFqn::methodName". * 2. Collect every StaticCall carrying ATTR_METHOD_GENERIC_ARGS — derive (classFqn, * methodName, args), generate a mangled method (cloning the template, substituting - * the type-param, renaming), append it to the owning class AST. + * the type-param, renaming), append it to the owning class AST — then drain the + * appends, re-walking each freshly specialized body so a forward that substitution + * just made concrete dispatches too. * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. + * Then `groundSpecializedClass()` runs per fresh class specialization inside the + * fixed-point loop, against the retained template index: a turbofish grounded by an + * ENCLOSING class type parameter (`self::gen::` inside `Box`) is abstract during + * Phase 1a and only becomes dispatchable once `Box`'s substitution rewrites the + * marker — own-template members land on the specialization itself, external targets on + * the retained user ASTs. * * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and @@ -94,6 +103,33 @@ final class GenericMethodCompiler public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; public const CODE_UNSPECIALIZED_GENERIC_CLOSURE = 'xphp.unspecialized_generic_closure'; + public const CODE_UNCONVERGED_METHOD_SPECIALIZATION = 'xphp.unconverged_method_specialization'; + + /** + * Cap on the append-drain's specialization chain depth. A freshly specialized + * function/method body may itself carry a now-concrete turbofish that mints a further + * specialization (`wrap` forwarding to `mid::` forwarding to `identity::`); + * same-args cycles terminate via the alreadyGenerated dedup, but a strictly-growing + * chain (`grow` calling `grow::>`) mints a new mangled name every hop and + * would never converge. Sixteen mirrors Compiler::MAX_SPECIALIZATION_DEPTH (kept as a + * separate constant — this pass must stay independent of the class-level pipeline). + */ + private const MAX_METHOD_SPECIALIZATION_HOPS = 16; + + /** + * Phase-1a state retained for the post-specialization grounding pass + * ({@see groundSpecializedClass}). `process()` strips generic templates from the + * user ASTs at the end of its run, but the index keeps referencing the detached + * template nodes — retaining it is what lets a marker that only became concrete + * under class specialization still find its method/function template. The dedup + * map is shared too, so a member a Phase-1a call already appended (or another + * specialization already grounded) is never appended twice. + */ + private ?TemplateIndex $retainedIndex = null; + /** @var array shared specialization-dedup keys (see rewriteStaticCall) */ + private array $alreadyGenerated = []; + /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ + private array $classSourceByFqn = []; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -173,6 +209,7 @@ public function process(array &$astSet, bool $emit = true): void } foreach ($perFileClasses as $k => $v) { $classByFqn[$k] = $v; + $this->classSourceByFqn[$k] = (string) $astKey; } foreach ($perFileFns as $k => $v) { $functionTemplates[$k] = $v; @@ -214,9 +251,11 @@ public function process(array &$astSet, bool $emit = true): void $functionNamespaceByFqn, $allFunctionsByFqn, ); + // Retain for the post-specialization grounding pass; the template nodes stay + // reachable through the index even after the strip loops below detach them. + $this->retainedIndex = $index; - /** @var array $alreadyGenerated */ - $alreadyGenerated = []; + $alreadyGenerated = &$this->alreadyGenerated; 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 @@ -280,6 +319,109 @@ public function process(array &$astSet, bool $emit = true): void } } + /** + * Ground + dispatch the method-generic turbofish markers inside one freshly + * specialized class. + * + * Runs from the fixed-point specialization loop, right after the class substitution + * and BEFORE the spec is collected: a marker like `self::gen::` or + * `Maker::wrap::` is abstract at the Phase-1a walk (the enclosing `T` has no + * value in the template) and only becomes dispatchable here, once the substitution + * has rewritten it to `::`. Re-uses the Phase-1a rewrite machinery against the + * retained template index, in the markers-only mode the append-drain introduced, + * plus a {@see GroundingContext} that redirects own-template member appends onto the + * spec itself (the template class lowers to a marker interface in output) and + * records externally-appended members so the caller can collect their nested + * instantiation needs into the same fixed point. + * + * Shapes deliberately NOT grounded here keep their marker and fall to the emit + * backstop exactly as before: instance-call markers, static calls whose declaring + * class is a *different* generic template, `static::`/`parent::` spellings (their + * default resolution would silently mis-ground, not fail), and forwards to a bare + * top-level function template (no container to append to from a detached walk). + * + * In `check` mode (`$emit = false`) nothing is attached; diagnostics only provable + * after substitution (a violated bound, a surviving marker) are collected so check + * and compile agree. + * + * @param list $classArgs the instantiation's concrete type arguments, + * parallel to the template's declared parameters + * @return list members appended onto containers other than the spec + * (user classes / namespaces) — the caller must collect + * these for nested instantiation discovery + */ + public function groundSpecializedClass( + ClassLike $specialized, + string $generatedFqn, + string $templateFqn, + array $classArgs, + bool $emit, + ): array { + $index = $this->retainedIndex; + if ($index === null) { + // process() never built an index (no templates anywhere) — with no method + // or function templates in the program there is nothing a marker could + // dispatch to; any survivor is the emit backstop's to report. + return []; + } + // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. + // skipUnspecializedTemplates matches the check-mode backstop below: in check + // mode a spec clone retains its (unstripped) generic-method templates, whose + // bodies carry call markers that the grounding walk deliberately skips — without + // this flag the pre-scan would see those and never take the cheap exit for any + // spec that declares a generic method. + if (GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + skipUnspecializedTemplates: true, + ) === null) { + return []; + } + + $currentFile = $this->classSourceByFqn[$templateFqn] ?? ""; + $grounding = new GroundingContext($specialized, $generatedFqn, $templateFqn, $classArgs); + /** @var list $topLevelAppends never grows in grounding mode (bare-template forwards keep their marker) */ + $topLevelAppends = []; + $this->rewriteCallSites( + [$specialized], + $index, + $this->alreadyGenerated, + $topLevelAppends, + $currentFile, + $emit, + $grounding, + ); + + // Check-mode parity backstop: compile rejects a still-marked spec at the emit + // loop's assertNoLeak; check has no emit loop, so collect the equivalent + // diagnostic here (call-marker arm only; sites the Phase-1a walk already + // reported — e.g. an unspecializable `$this` self-call — dedupe by position). + if (!$emit && $this->diagnostics !== null) { + // Variable-turbofish markers and the interiors of retained generic-method + // templates are excluded: check never materializes closure dispatchers nor + // strips templates, so a class-spec clone legitimately carries a `$f::` + // marker (compile's dispatcher pass grounds it) and template bodies with + // method-param markers (compile clones stripped classes) — flagging either + // would reject code compile accepts. + $leak = GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + includeVariableTurbofish: false, + skipUnspecializedTemplates: true, + ); + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $generatedFqn), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + + return $grounding->externalAppends; + } + /** * @param list $ast * @param array $methodTemplates out-param @@ -386,6 +528,7 @@ private function rewriteCallSites( array &$topLevelAppends, string $currentFile, bool $emit, + ?GroundingContext $grounding = null, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; @@ -410,9 +553,40 @@ private function rewriteCallSites( */ private NamespaceContext $nsContext; - /** @var list */ + /** + * Buffered specialized-member appends, flushed (and drained for freshly + * grounded markers) after the traversal. Slot 2 is the drain context: the + * declaring class FQN (methods; null for functions) and the namespace the + * specialized body resolves against — a drained stmt is traversed DETACHED, + * so the visitor's namespace/class state must be primed per item rather + * than inherited from whatever file the main walk last visited. + * + * @var list + */ public array $pendingAppends = []; + /** + * Markers-only mode for drain re-traversals of freshly specialized bodies. + * When true the rewrite pass touches ONLY named-call turbofish markers that + * substitution has made concrete; everything else — plain-call closure-arg + * sweeps, closure-dispatcher tracking (finalize has already run), orphan + * re-checks, static/instance marker rewrites (their name resolution is not + * drain-safe yet; a kept marker falls to the leak guard exactly as before) — + * is skipped so a drained body can neither duplicate diagnostics already + * reported against the template nor mis-ground through stale file state. + */ + public bool $markersOnly = false; + + /** + * Set (together with markersOnly) when this walk grounds a freshly + * specialized CLASS ({@see GenericMethodCompiler::groundSpecializedClass}). + * Widens the markers-only pass to static-call markers — their resolution + * is drain-safe here because names are attribute-resolved and the spec's + * own identity is threaded — and drives the append-target rule: an + * own-template member lands on the spec, deduped per specialization. + */ + public ?GroundingContext $grounding = null; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -582,8 +756,46 @@ public function __construct( $this->nsContext = new NamespaceContext(); } - public function enterNode(Node $node): null + /** + * Reset the visitor's lexical state for one drained (detached) specialized + * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, + * so enterNode never primes this state — left stale it would resolve names + * against whatever file the main traversal last walked. The use-alias map is + * cleared rather than reconstructed: names the drain needs are attribute- + * resolved (ATTR_TEMPLATE_FQN / ATTR_RESOLVED_FQN at parse time), so aliases + * are never consulted on the markers-only path. + */ + public function primeDrainScope(?string $classFqn, string $namespace): void { + $this->currentClassFqn = $classFqn; + $this->currentNamespace = $namespace; + $this->currentNamespaceNode = null; + $this->useMap = []; + $this->nsContext = new NamespaceContext(); + $this->nsContext->enterNamespace($namespace !== '' ? $namespace : null); + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; + $this->branchSnapshots = []; + $this->scopeSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; + $this->callReturnCache = []; + } + + public function enterNode(Node $node): null|int + { + // A markers-only walk skips generic declarations still carrying their + // template marker wholesale: in check mode nothing is stripped, so a + // spec clone contains the generic-method templates themselves, whose + // method-param-leaf markers the Phase-1a walk already validated — + // re-walking them would duplicate diagnostics (or false-flag closure + // templates the template walk already handled). leaveNode mirrors the + // test so it never pops a scope this skip never pushed. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } if ($node instanceof Namespace_) { $this->currentNamespace = $node->name?->toString() ?? ''; $this->currentNamespaceNode = $node; @@ -855,18 +1067,73 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { + // Markers-only re-walks must not re-diagnose a site the Phase-1a walk + // already reported (one collector message per source position): skip + // the rewrite wholesale — the kept marker falls to the backstops, which + // dedupe by the same position and stay silent. + if ($this->markersOnly + && ($node instanceof StaticCall || $node instanceof FuncCall + || $node instanceof MethodCall || $node instanceof NullsafeMethodCall) + && $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null + && $this->siteAlreadyReported($node->getStartLine()) + ) { + return null; + } if ($node instanceof StaticCall) { + if ($this->markersOnly) { + // Phase-1a drain (no grounding context): static resolution is + // not drain-safe there; a kept marker falls to the leak guard + // exactly as it did before the drain existed. Grounding a + // specialized class DOES process static markers — but only + // marker-bearing ones, and never the `static::`/`parent::` + // spellings: `resolveClassName` maps both to the current class, + // which would silently mis-ground a late-bound or parent-side + // dispatch; keeping the marker fails loudly instead. + if ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null + || $this->isLateBoundPseudoName($node->class) + ) { + return null; + } + } return $this->rewriteStaticCall($node); } if ($node instanceof FuncCall) { + // Drain traversals rewrite ONLY named-call turbofish markers: dispatch + // is ATTR_TEMPLATE_FQN-driven (no lexical resolution), so a detached + // body grounds safely. Bare calls and variable turbofish (`$f::<...>`) + // are skipped — dispatcher finalize has already run, and a surviving + // variable marker stays for the leak guard's closure arm. + if ($this->markersOnly + && (!$node->name instanceof Name + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteFuncCall($node); } if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) { + // Same rule as StaticCall: the Phase-1a drain leaves instance + // markers for the leak guard, but grounding a specialized class + // processes them — receiver identity/args come from the threaded + // context (`$this` = the spec) or the spec's already-substituted + // declared types. + if ($this->markersOnly + && ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteInstanceMethodCall($node); } if ($node instanceof ClassLike) { $this->currentClassFqn = null; } + // Mirror of enterNode's markers-only template skip: the enter never + // pushed a scope for this declaration, so the pop below must not run. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return null; + } if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure @@ -1197,6 +1464,29 @@ private function rewriteStaticCall(StaticCall $node): ?Node return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } [$template, $declaringFqn] = $resolved; + // Grounding mode, generic declaring template: groundable ONLY when the + // call site lexically lives inside the spec being grounded (a drained + // body appended onto ANOTHER class must not dispatch through this + // spec's `self::` — that emits a call to a member the other class + // doesn't have), AND the declaring template's parameters are threadable + // from the spec's own concrete arguments — its own template, or a + // generic ancestor through the extends chain. Anything else (a + // different generic template, an unthreadable chain, a foreign drained + // body) keeps its marker for the emit backstop. + $groundingClassSubst = null; + if ($this->grounding !== null && $this->isGenericTemplateClass($declaringFqn)) { + if ($this->currentClassFqn !== $this->grounding->templateFqn) { + return null; + } + $groundingClassSubst = $this->classSubstitutionFor( + $this->grounding->templateFqn, + $this->grounding->classArgs, + $declaringFqn, + ); + if ($groundingClassSubst->isEmpty()) { + return null; + } + } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -1273,6 +1563,52 @@ private function rewriteStaticCall(StaticCall $node): ?Node } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding a target declared on the spec's own template (or a generic + // ancestor — $groundingClassSubst threads the spec's arguments through + // the extends chain): the member lands on the spec itself — the + // template class lowers to a marker interface in output, so an append + // there would vanish (and mutating a shared template mid-loop would be + // order-dependent). Dedup per specialization, but consult the global + // key first: a member a concrete Phase-1a call already appended onto + // the template was cloned INTO this spec, and appending again would + // redeclare the method (load-time fatal). + if ($this->grounding !== null && $groundingClassSubst !== null) { + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + // Compose the declaring class's substitution under the method's + // own overlay: the detached template's body may reference class + // type parameters, which are concrete for THIS spec only. + $overlay = []; + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod( + $template, + $groundingClassSubst->withOverrides(Substitution::of($overlay)), + $mangled, + ); + // Drain context = the SPEC's template, not the declaring class: + // the member now lives on the spec, so `$this`/`self` inside its + // drained body are the spec — an ancestor-declared member whose + // body forwards again (`self::genB::` on Base) must pass the + // in-spec site guard, or a groundable chain leaks spuriously. + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + // Dispatch through `self`: the member lives on the emitted spec, and + // the template FQN spelling (`Box::gen` / `\App\Box::gen`) would + // resolve to the stripped marker interface. + $node->class = new Name('self', $node->class->getAttributes()); + return $node; + } + // Emit onto the declaring class (see the instance path) so subclasses // inherit the single specialization; dedup by the declaring FQN. $generatedKey = $declaringFqn . '::' . $mangled; @@ -1285,7 +1621,10 @@ private function rewriteStaticCall(StaticCall $node): ?Node $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1375,15 +1714,21 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): return null; } if (!self::allConcrete($padded)) { - // A non-concrete turbofish arg is an abstract type parameter forwarded from the - // enclosing generic method (`probe{ $this->contains::(...) }`). On a - // `$this`-rooted receiver this can't be specialized at the template — the arg is - // concrete only per instantiation. When the target is ERASABLE the Specializer - // rewrites this self-call to the target's E-mangled name per instantiation, so it - // resolves; leave it for that pass. Otherwise it would emit a bare `$this->m(...)` - // to a method that was never specialized (a runtime fatal) — so report it. + // A non-concrete turbofish arg on a `$this`-rooted receiver can't be + // specialized at the template — the arg is concrete only per + // instantiation. Three shapes: + // - ERASABLE target: the Specializer rewrites the self-call to the + // E-mangled member per instantiation; leave it for that pass. + // - Every abstract leaf is an ENCLOSING CLASS parameter + // (`$this->dup::(...)` inside `Box`): defer — the + // per-specialization grounding pass dispatches it once the class + // substitution makes it concrete. + // - A METHOD-level parameter leaf (`probe{ $this->dup::(...) }` + // on a non-erasable target): nothing downstream ever grounds it — + // report here, at the precise site, instead of a vaguer late leak. if ($this->receiverRootedAtThis($node->var) && !$this->isErasableTarget($template, $params, $declaringFqn) + && !$this->abstractLeavesAreEnclosingClassParams($padded) ) { return $this->reportUnspecializableSelfCall($methodName, $location); } @@ -1468,6 +1813,54 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding mode, generic declaring class: only a `$this`-rooted call + // FROM INSIDE THE SPEC BEING GROUNDED is groundable — there the + // receiver IS this spec, so the declaring template's substitution + // threads through the inheritance chain from the spec's own concrete + // args, and the member lands on the spec itself (the template lowers + // to a marker interface; mutating it mid-loop would be + // order-dependent). An OBJECT receiver of another generic template, or + // a `$this` inside a DRAINED body appended onto some other class + // (where `$this` is that class, not this spec), keeps its marker for + // the emit backstop. + if ($this->grounding !== null && $this->isGenericTemplateClass($declaringFqn)) { + if (!$this->receiverRootedAtThis($node->var) + || $this->currentClassFqn !== $this->grounding->templateFqn + ) { + return null; + } + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + $classSubst = $this->classSubstitutionFor( + $classFqn, + $this->resolveReceiverTypeArgs($node->var), + $declaringFqn, + ); + $overlay = []; + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod( + $template, + $classSubst->withOverrides(Substitution::of($overlay)), + $mangled, + ); + // Drain context = the SPEC's template (see the static arm): the + // member lives on the spec, so its drained body's `$this`/`self` + // are the spec and further own-chain forwards keep grounding. + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + // Key emission + dedup by the DECLARING class, not the receiver: the // specialization lands on the base and every subclass inherits the one // copy. Keying by receiver would append a duplicate per subclass. @@ -1480,7 +1873,10 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1662,6 +2058,12 @@ private function reportUnresolvedTurbofishOrSkip(string $receiverFqn, string $me // Plain (non-turbofish) call -- not a generic-resolution failure. return null; } + // Markers-only walks: the Phase-1a walk already diagnosed this site (the + // clone keeps its source position) — skip; a survivor falls to the emit + // backstop / check-mode leak diagnostic, which dedupe by position. + if ($this->markersOnly) { + return null; + } $message = self::unresolvedGenericCallMessage($receiverFqn, $methodName); if ($this->diagnostics !== null) { $this->diagnostics->add(new Diagnostic( @@ -1702,6 +2104,13 @@ private function reportUndeterminedReceiverOrSkip(string $methodName, Node $node if (!is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS))) { return null; } + // Markers-only walks re-visit call sites the Phase-1a walk already + // diagnosed (the clone keeps the same source position): skip instead of + // double-reporting; a genuinely un-grounded survivor still falls to the + // emit backstop / check-mode leak diagnostic, which dedupe by position. + if ($this->markersOnly) { + return null; + } $message = sprintf( 'Cannot determine the receiver\'s type for the generic call `%s::<...>()`. A ' . 'turbofish call is specialized at compile time, so the receiver must have a ' @@ -1891,6 +2300,15 @@ private function resolveReceiverTypeArgs(Node $receiver): array { if ($receiver instanceof Variable && is_string($receiver->name)) { if ($receiver->name === 'this') { + // Grounding a spec: `$this` IS the specialization — but ONLY + // for sites inside the spec's own body. In a drained body + // appended onto another class, `$this` is that class; fall + // through to the ordinary (lenient) resolution there. + if ($this->grounding !== null + && $this->currentClassFqn === $this->grounding->templateFqn + ) { + return $this->grounding->classArgs; + } $owner = $this->currentClassFqn !== null ? ($this->index->classLike($this->currentClassFqn)) : null; @@ -2358,6 +2776,14 @@ private function rewriteFuncCall(FuncCall $node): ?Node $generatedKey = 'fn::' . $mangledFqn; if (!isset($this->alreadyGenerated[$generatedKey])) { + // Grounding a specialized class: a BARE top-level function template + // has no container node, and the top-level bag routes into whatever + // file the walk was invoked for — which, for a detached spec, is no + // file at all (the append would be dropped silently). Keep the + // marker instead; the emit backstop rejects it loudly. + if ($this->grounding !== null && $this->index->functionNamespaceNode($fqn) === null) { + return null; + } $overlay = []; foreach ($params as $i => $param) { $overlay[$param->name] = $args[$i]; @@ -2368,7 +2794,10 @@ private function rewriteFuncCall(FuncCall $node): ?Node // Buffer the append — modifying $namespaceNode->stmts mid-traversal // doesn't reliably propagate through nikic's NodeTraverser. The // outer process() loop flushes pendingAppends after the walk. - $this->pendingAppends[] = [$namespaceNode, $specialized]; + $this->pendingAppends[] = [$namespaceNode, $specialized, [ + 'classFqn' => null, + 'namespace' => $namespace, + ]]; } else { // Bare top-level template (no enclosing `namespace { }` block): // there's no container to append to, so route the specialized @@ -2589,6 +3018,15 @@ private function buildForwardingFccClosure(string $varName, string $tag, FuncCal private function resolveClassName(Name $name): string { + // Markers-only walks run over DETACHED bodies with no use-alias state + // (primeDrainScope clears the map): the parse-time resolution attribute + // is authoritative there. Pseudo-names (`self`/`static`/`parent`) never + // carry it and keep the enclosing-class mapping below; the normal + // Phase-1a walk keeps its lexical resolution byte-identical. + $resolvedAttr = $name->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + if ($this->markersOnly && is_string($resolvedAttr)) { + return $resolvedAttr; + } $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 @@ -2737,41 +3175,340 @@ private static function lastSegment(string $name): string $pos = strrpos($name, '\\'); return $pos === false ? $name : substr($name, $pos + 1); } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespaceOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + + /** + * Whether the collector already holds a diagnostic at this source position + * (clones keep the template's line numbers). Markers-only walks re-visit + * call sites the Phase-1a walk already validated: re-running the rewrite on + * a site that already drew a diagnostic (an arity error, a bound failure) + * would re-fire the same collector message once per specialization. + */ + private function siteAlreadyReported(int $line): bool + { + if ($this->diagnostics === null) { + return false; + } + foreach ($this->diagnostics->all() as $diagnostic) { + // Errors only: a same-line WARNING must not suppress grounding — + // that would mask a real error the grounded walk would surface + // (check-green on code compile rejects, the dangerous direction). + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null + && $diagnostic->location->file === $this->currentFile + && $diagnostic->location->line === $line + ) { + return true; + } + } + return false; + } + + /** + * A function/method/closure declaration still carrying its generic-template + * marker — never present in a compile-mode spec (templates are stripped or + * lowered before cloning), but present in check-mode clones; markers-only + * walks skip them wholesale (see enterNode). + */ + private static function isUnspecializedTemplateDeclaration(Node $node): bool + { + return ($node instanceof ClassMethod + || $node instanceof Function_ + || $node instanceof Closure + || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); + } + + /** + * A `static::` / `parent::` class spelling — late-bound (or parent-side) + * dispatch that `resolveClassName`'s currentClassFqn mapping would silently + * mis-ground in a detached grounding walk. + */ + private function isLateBoundPseudoName(Node $class): bool + { + if (!$class instanceof Name) { + return false; + } + $first = strtolower($class->getParts()[0]); + return $first === 'static' || $first === 'parent'; + } + + /** + * Whether every abstract (type-param) leaf across the given TypeRef trees + * names a type parameter DECLARED BY THE ENCLOSING CLASS — the shape the + * per-specialization grounding pass can dispatch. A method-level parameter + * leaf (or any leaf when the enclosing class isn't generic) fails the test: + * no downstream pass ever grounds those. + * + * @param list $args + */ + private function abstractLeavesAreEnclosingClassParams(array $args): bool + { + if ($this->currentClassFqn === null) { + return false; + } + $classParams = $this->index->classLike($this->currentClassFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams — set as a list by XphpSourceParser::resolveAndAttach. */ + $names = []; + foreach ($classParams as $classParam) { + $names[$classParam->name] = true; + } + $walk = static function (TypeRef $ref) use (&$walk, $names): bool { + if ($ref->isTypeParam && !isset($names[$ref->name])) { + return false; + } + foreach ($ref->args as $inner) { + if (!$walk($inner)) { + return false; + } + } + return true; + }; + foreach ($args as $arg) { + if (!$walk($arg)) { + return false; + } + } + return true; + } + + /** Whether the FQN names a generic class template (declares type parameters). */ + private function isGenericTemplateClass(string $fqn): bool + { + $params = $this->index->classLike($fqn)?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + return is_array($params) && $params !== []; + } + }; + if ($grounding !== null) { + // Grounding a specialized class: markers-only walk, identity primed from + // the context (the spec clone is nameless and namespace-less — enterNode + // would never see a Namespace_/name to derive them from). + $visitor->markersOnly = true; + $visitor->grounding = $grounding; + $visitor->primeDrainScope( + $grounding->templateFqn, + self::namespacePrefixOf($grounding->templateFqn), + ); + } + $traverser = new NodeTraverser(); $traverser->addVisitor($visitor); $traverser->traverse($ast); // Runs BEFORE the emit gate: check mode must collect the orphan // diagnostics too (this is a validation, not an emission side-effect). - $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); - - // Validate-only (check) skips all emission: no dispatcher materialization, no buffered - // appends. The traversal above already produced the diagnostics via the call-site checks. - if (!$emit) { - return; + // Skipped in grounding mode: a markers-only walk records no attempts, so an + // inner generic closure the template walk already handled would false-flag + // as an orphan on every specialization. + if ($grounding === null) { + $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); } // 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. Each appended - // node is a fully specialized function/method: guard it against a surviving generic - // marker (a site that could not be grounded) before it reaches emitted output — the - // function-shaped counterpart to the specialized-class backstop in Compiler's emit - // loop. Compile-only: the `!$emit` gate above already returned for `check`. - foreach ($visitor->pendingAppends as [$container, $stmt]) { - $container->stmts[] = $stmt; - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + // site to inject the tag arg. Compile-only: it mutates shared Assign + // nodes and call sites, which check's discarded walk must not do. + if ($emit) { + $this->finalizeClosureDispatchers($visitor, $hashLength); + } + + // Drain the buffered appends now that the traversal has finished, so we don't + // fight nikic's NodeTraverser's child-array iteration semantics mid-walk. Runs + // in BOTH modes: compile attaches, grounds, and backstops each appended body; + // check re-traverses the (discarded) bodies validate-only so diagnostics that + // only become provable after substitution are collected — keeping check and + // compile verdicts aligned. + $this->drainSpecializedAppends($visitor, $currentFile, $emit); + } + + /** + * Flush the buffered specialized appends as a grounding worklist. + * + * Each buffered stmt is a freshly specialized function/method whose body may itself + * carry method-generic turbofish markers that substitution has made concrete + * (`identity::` inside `wrap` becomes `identity::` inside the buffered + * `wrap_T_`). A flat flush would emit those bodies ungrounded — the leak-guard + * backstop tripped on exactly that shape. Instead each stmt is attached (compile + * mode), then re-traversed with the same rewrite visitor in markers-only mode so a + * named-forward marker dispatches and may buffer further appends; the loop repeats + * until both queues drain. Same-args cycles (`a` forwarding to `b` forwarding + * back) terminate through the shared alreadyGenerated dedup — the second visit finds + * the key set and only rewrites the call. A strictly-growing chain mints a fresh + * mangled name every hop and is cut off at MAX_METHOD_SPECIALIZATION_HOPS with a + * loud non-convergence error instead of an endless compile. + * + * Check mode traverses without attaching (the walked ASTs are discarded) and + * degrades both the non-convergence error and the leak backstop to collected + * diagnostics, so `xphp check` reports the shapes `compile` rejects. + */ + private function drainSpecializedAppends(object $visitor, string $currentFile, bool $emit): void + { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = true; + // @infection-ignore-all UnwrapFinally — the reset is defensive hygiene: this drain is + // the visitor's last use (one visitor per rewriteCallSites call), so a leftover + // markersOnly=true is dead state today; the finally guards future reuse, not behavior. + try { + $pendingIdx = 0; + $topLevelIdx = 0; + /** @var array $hopDepth spl_object_id(stmt) => chain depth; absent = 1 (buffered by the user-code walk) */ + $hopDepth = []; + while (true) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($pendingIdx < count($visitor->pendingAppends)) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + [$container, $stmt, $context] = $visitor->pendingAppends[$pendingIdx++]; + if ($emit) { + $container->stmts[] = $stmt; + } + // Grounding mode: a member appended onto anything but the spec + // itself (a non-generic user class, a function namespace) is + // invisible to the fixed-point loop's spec collection — record it + // so the caller can collect its nested instantiation needs. In + // check mode NOTHING is attached, so own-spec members are equally + // invisible to the spec collect and must be recorded too — or a + // bound violation nested in a grounded member's body (`new + // Pair::` inside `gen`) passes check while compile rejects. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($visitor->grounding !== null && (!$emit || $container !== $visitor->grounding->spec)) { + $visitor->grounding->externalAppends[] = $stmt; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { + // Top-level (null-namespace) functions have no container node here; + // process() flushes them into the top-level AST array after this + // method returns. They are still grounded + leak-checked like any + // other append. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $stmt = $visitor->topLevelAppends[$topLevelIdx++]; + $context = ['classFqn' => null, 'namespace' => '']; + } else { + break; + } + + // @infection-ignore-all IncrementInteger DecrementInteger — shifting the + // initial depth by one only offsets where the cap lands (16±1 hops); the + // behavior — a growing chain halts loudly with the unconverged code — is + // pinned by the growth fixtures, and the exact allowance is not contract. + $depth = $hopDepth[spl_object_id($stmt)] ?? 1; + if ($depth > self::MAX_METHOD_SPECIALIZATION_HOPS) { + $message = sprintf( + 'Generic method/function specialization did not converge: grounding "%s" ' + . '(in %s) is %d specialization hops deep — each hop mints a new type argument ' + . '(e.g. a generic forwarding to itself with a nested `Box`), so the chain ' + . 'would never terminate. Break the growth by forwarding a concrete turbofish. [%s]', + $stmt->name->toString(), + $currentFile, + $depth, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + ); + if (!$emit && $this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + $message, + new SourceLocation($currentFile, $stmt->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + + // @phpstan-ignore-next-line method.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->primeDrainScope($context['classFqn'], $context['namespace']); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforePending = count($visitor->pendingAppends); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforeTopLevel = count($visitor->topLevelAppends); + + $traverser = new NodeTraverser(); + assert($visitor instanceof NodeVisitorAbstract); + $traverser->addVisitor($visitor); + $traverser->traverse([$stmt]); + + // @infection-ignore-all IncrementInteger Plus — a coarser per-hop increment + // only halves/offsets the cap allowance; growth still halts loudly with the + // unconverged code (pinned by the growth fixtures) at the same reported depth. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforePending; $i < count($visitor->pendingAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->pendingAppends[$i][1])] = $depth + 1; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforeTopLevel; $i < count($visitor->topLevelAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->topLevelAppends[$i])] = $depth + 1; + } + + $label = $currentFile . ' (' . $stmt->name->toString() . ')'; + if ($emit) { + GenericMarkerLeakGuard::assertNoLeak($stmt, $label); + } elseif ($this->diagnostics !== null) { + // Call-marker arm only: an un-specialized closure template in a + // drained body is always reported elsewhere (seam or orphan check). + $leak = GenericMarkerLeakGuard::findLeak($stmt, includeClosureTemplates: false); + // Suppress the backstop when the site already carries a diagnostic: + // the cloned body preserves the template's line numbers, so a shape + // the source seam rejected with a precise error (e.g. a non-concrete + // variable turbofish, CODE_UNSPECIALIZED_GENERIC_CLOSURE) would + // otherwise double-report here under the vaguer leak code. + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $label), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + } + } finally { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = false; } - foreach ($topLevelAppends as $stmt) { - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespacePrefixOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + + /** + * Whether the collector already holds a diagnostic at this exact source position. + * Used by the drain's check-mode backstop to avoid re-reporting a site the source + * seam rejected with a more precise code. + */ + private function alreadyReportedAt(string $file, int $line): bool + { + if ($this->diagnostics === null) { + return false; + } + foreach ($this->diagnostics->all() as $diagnostic) { + // Errors only — a same-line warning must not swallow the leak backstop. + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null + && $diagnostic->location->file === $file + && $diagnostic->location->line === $line + ) { + return true; + } } + return false; } /** @@ -2857,7 +3594,10 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v foreach ($result['declarations'] as $specialized) { if ($entry['namespaceNode'] !== null) { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. - $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized]; + $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized, [ + 'classFqn' => null, + 'namespace' => $entry['namespace'], + ]]; } else { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. $visitor->topLevelAppends[] = $specialized; diff --git a/src/Transpiler/Monomorphize/GroundingContext.php b/src/Transpiler/Monomorphize/GroundingContext.php new file mode 100644 index 0000000..8b43bd1 --- /dev/null +++ b/src/Transpiler/Monomorphize/GroundingContext.php @@ -0,0 +1,48 @@ +` → `self::gen::`). Grounding them re-uses the Phase-1a + * rewrite machinery, but three decisions differ from a user-file walk and are driven by + * this context: + * + * - **identity**: the walked ClassLike is a nameless clone; `templateFqn` stands in as + * the current class FQN so `self::` resolution and method-template lookup key against + * the retained Phase-1a index, and `classArgs` are the instantiation's concrete type + * arguments (parallel to the template's declared parameters) for composing the class + * substitution into method specialization. + * - **append target**: a member specialized from the spec's own template lands on the + * spec itself (`spec`), deduped per specialization via `generatedFqn` — never on the + * template class, which lowers to a marker interface in emitted output. + * - **external collection**: members appended onto OTHER containers (a non-generic user + * class, a function namespace) are recorded in `externalAppends` so the fixed-point + * loop can collect their nested instantiation needs — user files were collected before + * these members existed. + */ +final class GroundingContext +{ + /** @var list members appended onto containers other than the spec */ + public array $externalAppends = []; + + /** + * @param list $classArgs concrete instantiation args, parallel to the + * template's declared type parameters + */ + public function __construct( + public readonly ClassLike $spec, + public readonly string $generatedFqn, + public readonly string $templateFqn, + public readonly array $classArgs, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index bc2c28d..f4301bc 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -9,6 +9,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -331,15 +332,27 @@ public function __construct(private Substitution $substitution) public function leaveNode(Node $node): ?Node { - // Ground a variable-turbofish call's type arguments in place. An inner + // Ground a method-generic turbofish call's type arguments in place. An inner // `$inner::(...)` inside a generic template body parses as a FuncCall on - // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; when the - // enclosing generic specializes (`S → int`) those args must ground too, so - // the per-specialization closure-grounding pass sees `$inner::` and can - // dispatch it. Left un-substituted the closure keeps a raw `I` hint and - // fatals at runtime. (This substitution is e2e-inert until that grounding - // pass runs — it only rewrites the recorded type args, never emits.) - if ($node instanceof FuncCall) { + // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; a static / + // instance / nullsafe method turbofish (`Maker::wrap::`, `$this->m::`) + // carries the same attribute on its call node. When the enclosing generic + // specializes (`T → int`) those recorded args must ground too, so a + // downstream grounding pass sees `::` and can dispatch it. Left + // un-substituted the call keeps a raw `T` ref and is rejected by the emit + // leak guard. (This substitution is e2e-inert until a grounding pass + // consumes it — it only rewrites the recorded type args, never emits.) + // @infection-ignore-all LogicalOrAllSubExprNegation — node classes are + // mutually exclusive, so the all-negated disjunction is a tautology + // (every node passes); the arm is still gated on the marker attribute, + // which only call nodes carry, so the mutant is observationally + // equivalent. The per-kind instanceof checks are pinned positively by + // SpecializerMethodGenericArgsTest's call-kind provider. + if ($node instanceof FuncCall + || $node instanceof StaticCall + || $node instanceof MethodCall + || $node instanceof NullsafeMethodCall + ) { $methodArgs = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (is_array($methodArgs) && $methodArgs !== []) { /** @var list $methodArgs — ATTR_METHOD_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser). */ diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index cd46e6a..58460ad 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; @@ -43,6 +44,249 @@ public function testCleanSourcesProduceNoDiagnostics(): void self::assertSame([], $diagnostics->all()); } + public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void + { + // `wrap` forwarding `identity::` is grounded per specialization by the + // append-drain; the validate-only walk must agree with compile and report + // nothing — no duplicate diagnostics from the drain's re-traversal either. + $diagnostics = $this->check('forward_named_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testMethodTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box` ground per + // specialization; the validate-only pass must agree with compile and report + // nothing — across two instantiations and two forwarding classes. + $diagnostics = $this->check('method_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedStaticBoundViolationIsCollectedByCheck(): void + { + // `gen` grounded with `T = int`: provable only after + // Box specializes, collected by the per-specialization grounding pass. + // The location must point at the template's real source file (the grounding + // pass resolves it through the retained class-source map), not a synthetic + // `` label. + $diagnostics = $this->check('method_turbofish_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + } + + public function testClassClosureDispatcherIsCleanInCheck(): void + { + // A concrete `$f::` inside a generic CLASS method: compile materializes + // the dispatcher and the program runs, so check must stay silent — the spec + // clone's leftover variable marker (check never finalizes dispatchers) is not + // a leak. + $diagnostics = $this->check('class_closure_dispatcher_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testNestedBoundViolationInsideGroundedMemberIsCollectedByCheck(): void + { + // The violation lives inside the grounded member's body (`new Pair::` with + // `Pair

`, grounded to Pair): own-spec members are collected + // even though check attaches nothing, so check agrees with compile's reject. + $diagnostics = $this->check('method_turbofish_nested_bound'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void + { + // An arity error on a deferred enclosing-param turbofish under TWO + // instantiations: one diagnostic at the source site — the per-spec grounding + // walks must not re-fire the same collector message per specialization. + $diagnostics = $this->check('method_turbofish_arity_once'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); + } + + public function testTwoHopOwnTemplateForwardChainIsCleanInCheck(): void + { + // Compile grounds `go` → `self::a::` → `self::b::` and the program + // runs; check must stay silent — its un-stripped spec clone retains the + // `a`/`b` templates, whose interior method-param markers are dispatch + // machinery, not leaks. + $diagnostics = $this->check('method_turbofish_two_hop_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameLineWarningDoesNotMaskAGroundedError(): void + { + // A warning-producing construct shares the source line with the deferred + // turbofish: the position dedupe is severity-aware, so grounding still runs + // and the bound violation nested in the grounded member surfaces — check + // must not go green on code compile rejects. + $diagnostics = $this->check('method_turbofish_warning_same_line'); + + self::assertTrue($diagnostics->hasErrors()); + $errorCodes = array_map( + static fn ($d) => $d->code, + array_filter($diagnostics->all(), static fn ($d) => $d->severity === Severity::Error), + ); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $errorCodes); + } + + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void + { + // The compile-side keep-marker contract for a template-owned target named + // outside the template's own body (see the compile reject fixture) holds in + // check too: exactly one leak diagnostic, at the real source site. + $diagnostics = $this->check('template_target_outside_spec'); + + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void + { + // `Other::gen::` (a static method-generic on a DIFFERENT generic template) + // stays un-grounded by design; compile rejects at the emit backstop, and check + // must collect the same leak diagnostic from the grounding pass — located at + // the template's real source file. + $diagnostics = $this->check('method_turbofish_cross_template'); + + self::assertTrue($diagnostics->hasErrors()); + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testClassParamBoundOnStaticIsStillUnprovableInCheck(): void + { + // `gen` on a static method-generic: genuinely unprovable in a static + // context — the pre-existing rejection survives the grounding pass in check + // exactly as in compile. + $diagnostics = $this->check('method_turbofish_unprovable'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + + public function testInstanceTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `$this->dup::` / `$m->dup::` inside `Holder` ground per + // specialization; the validate-only pass must agree with compile and + // report nothing. + $diagnostics = $this->check('instance_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testInstanceGroundedBoundViolationIsCollectedByCheck(): void + { + // `need` grounded with `T = int` through `$this`: + // collected by the grounding pass, located at the template's real file. + $diagnostics = $this->check('instance_turbofish_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + } + + public function testMethodParamLeafSelfCallIsCollectedByCheck(): void + { + // `$this->dup::` inside `probe`: deferral is reserved for + // class-param leaves, so check keeps the precise Phase-1a diagnostic + // (exactly one — the grounding pass must not add a leak duplicate). + $diagnostics = $this->check('instance_turbofish_method_param'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame( + GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, + $diagnostics->all()[0]->code, + ); + } + + public function testNeverInstantiatedDeferredMarkerIsCleanInCheck(): void + { + // A deferred enclosing-param turbofish in a never-instantiated generic + // class: unreachable code, no diagnostic (deliberate surface choice, + // matching compile). + $diagnostics = $this->check('instance_never_instantiated'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void + { + // `need` forwarded `T = int`: provable only after `wrap::` + // substitutes, so it surfaces from the drain's validate-only traversal — + // matching the compile-side throw (check/compile parity). + $diagnostics = $this->check('forward_named_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testUnconvergedForwardChainIsCollectedByCheck(): void + { + // `grow` forwarding `grow::>` never converges; check collects the + // drain's hop-cap diagnostic instead of hanging or throwing. + $diagnostics = $this->check('forward_growth'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $codes); + } + + public function testEachUnconvergedChainGetsItsOwnDiagnosticInCheck(): void + { + // Two independent growing chains: hitting the cap on the first stops that + // chain only — the drain keeps going and the second chain reports too. + $diagnostics = $this->check('forward_growth_pair'); + + $unconverged = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + )); + self::assertCount(2, $unconverged); + } + + public function testInnerClosureTurbofishLeakIsCollectedByCheck(): void + { + // A concrete inner closure turbofish (`$f::` inside `outer`) survives + // the drain (variable turbofish stays out of the markers-only pass) and is + // degraded from the compile-time leak throw to a collected diagnostic here. + $diagnostics = $this->check('forward_inner_closure_leak'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMarkerLeakGuard::CODE, $codes); + } + public function testDefaultBoundViolationIsCollectedByCheck(): void { // Exercises the validateDefaultsAgainstBounds() step of check(). diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index d95b525..4cc61d4 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -356,6 +356,120 @@ function bareId(T $x): T } } + public function testNamedForwardGroundedByEnclosingParamSpecializes(): void + { + // `wrap` forwards `identity::($v)` — abstract in the template, concrete + // after `wrap::` / `wrap::` specialize. The append-drain grounds + // each specialized body and dispatches the forward into a real + // `identity_T_` declaration; no marker (and no raw `identity(`) survives. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward', + ); + try { + $content = file_get_contents($fixture->targetDir . '/Use.php'); + self::assertIsString($content); + + // Two instantiations → two wrap + two forwarded identity specializations. + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $content)); + self::assertSame(2, preg_match_all('/function identity_T_[0-9a-f]+\(/', $content)); + // Each specialized wrap body calls the matching identity specialization. + self::assertSame(2, preg_match_all('/return \\\\App\\\\NamedForward\\\\identity_T_[0-9a-f]+\(/', $content)); + // Negative invariants: templates stripped, no un-rewritten forward survives + // (`identity::<` only appears in the carried-over source comment's prose). + self::assertStringNotContainsString('function wrap(', $content); + self::assertStringNotContainsString('function identity(', $content); + self::assertStringNotContainsString('return identity::<', $content); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php', + $content, + ); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testNamedForwardRuntimeExecution(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward-runtime', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testForwardChainAndSameArgsCycleRuntimeExecution(): void + { + // 2-hop chain (`wrap` → `mid` → `identity`) and same-args mutual recursion + // (`ping` ↔ `pong`): the drain keeps grounding freshly appended bodies until + // the queue empties, and the specialization dedup terminates the cycle. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_chain/source', + 'genfn-forward-chain', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGrowingForwardChainIsRejectedAsUnconverged(): void + { + // `grow` forwards `grow::>` — every hop mints a deeper type argument, + // so the chain can never converge. The drain's hop cap rejects it loudly. The + // reported depth pins the cap boundary exactly: sixteen allowed hops, failing + // on the seventeenth. + try { + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_reject/source', + 'genfn-forward-growth', + ); + self::fail('expected the growing chain to be rejected'); + } catch (RuntimeException $e) { + self::assertStringContainsString(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $e->getMessage()); + self::assertStringContainsString('is 17 specialization hops deep', $e->getMessage()); + } + } + + #[RunInSeparateProcess] + public function testGrowingBareTopLevelForwardChainIsRejectedAsUnconverged(): void + { + // Top-level variant: specializations route through the top-level append bag + // (no Namespace_ container), whose queue the hop cap must bound identically — + // an unbounded bare-file chain would otherwise specialize forever. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_bare_reject/source', + 'genfn-forward-growth-bare', + ); + } + + #[RunInSeparateProcess] + public function testGroundedForwardBoundViolationFailsCompilation(): void + { + // `need` forwarded `T = int` — the violation is only provable + // after `wrap::` substitutes, so it must fail at the grounding drain. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\ForwardBound\need'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_bound_reject/source', + 'genfn-forward-bound', + ); + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 0440700..073ef43 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -140,4 +140,133 @@ public function testAcceptsAListOfNodesAndScansEach(): void $this->expectException(RuntimeException::class); GenericMarkerLeakGuard::assertNoLeak([$clean, new Expression($leaking)], 'list'); } + + public function testFindLeakReturnsTheLeakingNodeAndNullOnCleanInput(): void + { + // The check-mode drain consumes the scan directly (degrading to a diagnostic + // instead of a throw), so the found node — not just the boolean outcome — is API. + $leaking = new StaticCall(new Name('self'), new Identifier('gen')); + $leaking->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + self::assertSame($leaking, GenericMarkerLeakGuard::findLeak(new Expression($leaking))); + self::assertNull(GenericMarkerLeakGuard::findLeak(new Expression(new FuncCall(new Variable('a'))))); + } + + public function testFindLeakCanExcludeTheClosureTemplateArm(): void + { + // With $includeClosureTemplates=false only call-node markers count: an + // un-specialized closure template is reported elsewhere (source seam / orphan + // check), so the check-mode drain must not re-flag the template node itself. + $closure = new Closure(['stmts' => []]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('I')]); + $body = new Expression($closure); + + self::assertSame($closure, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeClosureTemplates: false)); + + // A call-node marker still counts with the closure arm off. + $call = new FuncCall(new Variable('f')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); + } + + public function testFindLeakCanExcludeVariableTurbofishCalls(): void + { + // With $includeVariableTurbofish=false a FuncCall on a VARIABLE (`$f::`) + // is not a leak — check's class-spec backstop uses this because dispatchers + // are only materialized in compile mode. Named calls still count. + $varCall = new FuncCall(new Variable('f')); + $varCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = new Expression($varCall); + + self::assertSame($varCall, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeVariableTurbofish: false)); + + $namedCall = new FuncCall(new Name('identity')); + $namedCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $namedCall, + GenericMarkerLeakGuard::findLeak(new Expression($namedCall), includeVariableTurbofish: false), + ); + + // Static/instance markers are unaffected by the toggle. + $static = new StaticCall(new Name('self'), new Identifier('gen')); + $static->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $static, + GenericMarkerLeakGuard::findLeak(new Expression($static), includeVariableTurbofish: false), + ); + } + + public function testFindLeakCanSkipUnspecializedTemplateInteriors(): void + { + // With $skipUnspecializedTemplates=true, the SUBTREE of a declaration still + // carrying its generic-template marker is not scanned: check-mode spec clones + // retain method templates whose interior markers are dispatch machinery, not + // leaks. Each declaration kind prunes independently. + $makeMarkedCall = static function (): StaticCall { + $call = new StaticCall(new Name('self'), new Identifier('gen')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + return $call; + }; + + $method = new \PhpParser\Node\Stmt\ClassMethod('a', ['stmts' => [new Return_($makeMarkedCall())]]); + $method->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $function = new \PhpParser\Node\Stmt\Function_('f', ['stmts' => [new Return_($makeMarkedCall())]]); + $function->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $closure = new Closure(['stmts' => [new Return_($makeMarkedCall())]]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $arrow = new ArrowFunction(['expr' => $makeMarkedCall()]); + $arrow->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + + foreach ([$method, $function, new Expression($closure), new Expression($arrow)] as $decl) { + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false), + 'without the skip, the interior marker is a leak', + ); + self::assertNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false, skipUnspecializedTemplates: true), + 'with the skip, the template interior is pruned', + ); + } + + // A declaration WITHOUT the template marker is scanned normally... + $plainMethod = new \PhpParser\Node\Stmt\ClassMethod('b', ['stmts' => [new Return_($makeMarkedCall())]]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($plainMethod, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + // ...and the params attribute on a NON-declaration node never prunes (the skip + // is scoped to the four declaration kinds precisely). + $decoy = new Expression($makeMarkedCall()); + $decoy->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decoy, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + } + + public function testFindLeakSkippingScanReturnsTheFirstLeakInDocumentOrder(): void + { + // The pruning scan must report the same FIRST leak the plain scan does — a + // later sibling must never overwrite it. + $first = new StaticCall(new Name('self'), new Identifier('one')); + $first->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $second = new StaticCall(new Name('self'), new Identifier('two')); + $second->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = [new Expression($first), new Expression($second)]; + + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body, skipUnspecializedTemplates: true)); + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body)); + } + + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void + { + $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + $message = GenericMarkerLeakGuard::leakMessage($call, 'wrap_T_cafe'); + + self::assertStringContainsString('wrap_T_cafe', $message); + self::assertStringContainsString('7', $message); + self::assertStringContainsString(GenericMarkerLeakGuard::CODE, $message); + } } diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 9944ad0..d27bc45 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -12,16 +12,22 @@ /** * End-to-end coverage for the emitted-generic-marker backstop ({@see GenericMarkerLeakGuard}). * - * Two enclosing-parameter / generic-function-scope turbofish shapes slip past the source-level - * gates and reach the emit phase un-grounded: a *concrete* inner closure turbofish inside a - * generic function body, and a method turbofish grounded by an enclosing class type parameter. - * Left to emit, each produces PHP that references a non-existent type-parameter class (a runtime - * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud - * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. + * The turbofish shapes that deliberately stay un-groundable reach the emit phase with their + * marker intact: a *concrete* inner closure turbofish inside a generic function body, a static + * method-generic declared on a *different* generic template, and the late-bound + * `static::`/`parent::` spellings. Left to emit, each produces PHP that references a + * non-existent type-parameter class or the wrong dispatch target behind an otherwise clean + * compile. The backstop turns each into a loud compile failure carrying + * `xphp.unspecialized_generic_leak` before any output is written. * - * (The third shape — a generic closure grounded by an enclosing *function* parameter, `relay` — - * is a non-concrete variable turbofish caught earlier at the source seam in both modes; see - * {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}.) + * (Adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing *function* + * parameter, `relay`, is a non-concrete variable turbofish caught earlier at the source seam in + * both modes — see {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}; + * a NAMED free-function forward (`identity::` inside `wrap`) is grounded by the + * append-drain — see `generic_function_named_forward` in {@see GenericFunctionIntegrationTest}; + * and an own-template / non-generic-target method turbofish grounded by the enclosing class + * parameter (`self::gen::`, `Maker::wrap::`) is grounded per specialization — see + * `generic_class_method_turbofish` in {@see GenericMethodIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -43,31 +49,66 @@ public function testConcreteInnerTurbofishInsideAGenericFunctionIsRejected(): vo } #[RunInSeparateProcess] - public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): void + public function testCrossTemplateStaticTurbofishIsRejected(): void { + // A static method-generic declared on a DIFFERENT generic template + // (`Other::gen::` from inside `Holder`): the grounding pass deliberately + // leaves the marker (it would need Other's own substitution mapping, and + // appending onto a shared template mid-loop is order-dependent), so the + // backstop rejects. The own-template and non-generic-target forms of the same + // call shape ground and run — see the generic_class_method_turbofish fixture. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_leak_reject/source', - 'generic-class-method-turbofish-leak', + __DIR__ . '/../../fixture/compile/generic_class_cross_template_turbofish_reject/source', + 'generic-class-cross-template-turbofish', ); } #[RunInSeparateProcess] - public function testNamedFreeFunctionForwardGroundedByEnclosingParamIsRejected(): void + public function testTemplateTargetOutsideItsOwnSpecIsRejected(): void { - // A named generic free function forwarded a non-concrete type argument from an - // enclosing function parameter (`identity::($v)` inside `wrap`). The named-call - // turbofish is not a variable turbofish, so it slips past the source seam and reaches - // emit as an appended `wrap_T_` with the marker still present — caught by the - // backstop. Same class of shape as the concrete-inner-turbofish case. + // `Holder::gen::` written inside Maker's body (a NON-member of Holder): + // grounding Holder drains Maker's freshly appended member, but the + // own-template arm must not fire there — a `self::` rewrite inside Maker + // would call a member Maker doesn't have (a runtime fatal). Keep-marker + + // backstop is the contract. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_function_named_forward_leak_reject/source', - 'generic-function-named-forward-leak', + __DIR__ . '/../../fixture/compile/generic_class_template_target_outside_spec_reject/source', + 'generic-class-target-outside-spec', + ); + } + + #[RunInSeparateProcess] + public function testStaticPseudoNameTurbofishIsRejected(): void + { + // `static::gen::`: honoring late static binding is impossible for the + // grounding pass, and resolving `static` to the current class would silently + // re-route a subclass dispatch — keep-marker + loud reject is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_pseudo_turbofish_reject/source', + 'generic-class-static-pseudo-turbofish', + ); + } + + #[RunInSeparateProcess] + public function testParentPseudoNameTurbofishIsRejected(): void + { + // `parent::gen::`: same contract as `static::` — the current-class mapping + // would dispatch to the wrong side of the hierarchy, so the marker is kept. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_parent_pseudo_turbofish_reject/source', + 'generic-class-parent-pseudo-turbofish', ); } } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 42aff92..5d329c8 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1699,6 +1699,301 @@ class Box { public static function get(T $x): T { return $x; } } } } + #[RunInSeparateProcess] + public function testMethodTurbofishGroundedByEnclosingClassParamCompiles(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box`: abstract in the + // template, grounded and dispatched per specialization. The spec carries its + // own `gen_T_` member (dispatched via `self::`, appended once despite + // two call sites) and the non-generic Maker gets one shared `wrap_T_` + // per unique argument tuple. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/source', + 'genmethod-enclosing-turbofish', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + self::assertCount(3, $generated, 'Box, Box, CoBox'); + + $specs = array_combine($generated, array_map('file_get_contents', $generated)); + $boxIntSpec = null; + foreach ($specs as $path => $content) { + self::assertIsString($content); + if (str_contains($path, '/Box/') && str_contains($content, 'make(int $v): int')) { + $boxIntSpec = $content; + } + } + self::assertIsString($boxIntSpec, 'Box spec found'); + // One appended member, dispatched via self:: (never the marker interface). + self::assertSame(1, preg_match_all('/function gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertSame(2, preg_match_all('/self::gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertStringNotContainsString('Box::gen', $boxIntSpec); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php', + $boxIntSpec, + ); + + // Maker (a plain user file) carries exactly two wrap specializations — + // int (shared by Box and CoBox) and string. + $maker = file_get_contents($fixture->targetDir . '/Maker.php'); + self::assertIsString($maker); + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $maker)); + + $fixture->registerAutoload('App\\MethodTurbofish'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundingAppendedMemberInstantiationsAreCollected(): void + { + // `Pair` first exists inside the `twin_T_` member the grounding + // pass appends onto Maker — its instantiation must be collected into the + // fixed point or the emitted call references a missing generated class. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/source', + 'genmethod-turbofish-discovery', + ); + try { + $pairSpecs = array_filter( + self::globRecursive($fixture->cacheDir . '/Generated', '*.php'), + static fn (string $p): bool => str_contains($p, '/Pair/'), + ); + self::assertCount(1, $pairSpecs, 'Pair was discovered and specialized'); + + $fixture->registerAutoload('App\\TurbofishDiscovery'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundViolationFailsCompilation(): void + { + // `gen` grounded with `T = int` once Box specializes: + // provable only after substitution, must fail at the grounding pass. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_bound/source', + 'genmethod-turbofish-bound', + ); + } + + #[RunInSeparateProcess] + public function testClassParamBoundOnStaticStaysUnprovable(): void + { + // `gen` on a STATIC method: a class-param bound has no receiver to + // ground it in a static context — the pre-existing rejection must survive + // the grounding pass unchanged. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : T` for App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_unprovable/source', + 'genmethod-turbofish-unprovable', + ); + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundSatisfiedCompilesAndRuns(): void + { + // The bound twin that must keep compiling: `gen` grounded + // with a T that satisfies the bound. + $dir = sys_get_temp_dir() . '/xphp-genmethod-bound-ok-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + s; } + } + class Box { + public function m(T $v): T { return self::gen::($v); } + public static function gen(U $x): U { return $x; } + } + $b = new Box::

+{ + public function __construct(public readonly P $v) + { + } +} + +final class Holder +{ + public function go(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp new file mode 100644 index 0000000..739c685 --- /dev/null +++ b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/check/method_turbofish_unprovable/source/Use.xphp b/test/fixture/check/method_turbofish_unprovable/source/Use.xphp new file mode 100644 index 0000000..4bb668f --- /dev/null +++ b/test/fixture/check/method_turbofish_unprovable/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function m(T $v): T + { + return self::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp new file mode 100644 index 0000000..c3f7e98 --- /dev/null +++ b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp @@ -0,0 +1,48 @@ + +{ + public function __construct(public readonly P $v) + { + } +} + +class Producer +{ + public function __construct(public readonly mixed $seed) + { + } +} + +final class Book +{ +} + +// A WARNING and a deferred turbofish share one source line: the position dedupe is +// severity-aware, so the warning must not suppress grounding — the bound violation +// nested in gen's grounded body still surfaces (check-green here while compile +// rejects would be the dangerous direction). +final class Holder +{ + public function go(T $v): mixed + { + $x = [new Producer::(1), self::gen::($v)]; return $x; + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/template_target_outside_spec/source/Use.xphp b/test/fixture/check/template_target_outside_spec/source/Use.xphp new file mode 100644 index 0000000..d149086 --- /dev/null +++ b/test/fixture/check/template_target_outside_spec/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp new file mode 100644 index 0000000..5f2b061 --- /dev/null +++ b/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp @@ -0,0 +1,28 @@ + +{ + public static function gen(U $x): U + { + return $x; + } +} + +class Holder +{ + public function m(T $v): T + { + return Other::gen::($v); + } +} + +$h = new Holder::(); +$h->m(1); diff --git a/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp b/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp new file mode 100644 index 0000000..47cd715 --- /dev/null +++ b/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp @@ -0,0 +1,35 @@ + +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +$c = new Consumer(); +$out = $c->poke(); diff --git a/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp b/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp new file mode 100644 index 0000000..2c73d9a --- /dev/null +++ b/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp @@ -0,0 +1,31 @@ +` target: the grounded call must reuse the E-mangled member the erasure +// lowering already emitted per instantiation — appending a second same-named member +// would be a class-load fatal. +class Box +{ + /** @param array $items */ + public function __construct(private array $items) + { + } + + public function contains(U $needle): bool + { + return in_array($needle, $this->items, true); + } + + public function has(E $x): bool + { + return $this->contains::($x); + } +} + +$b = new Box::([1, 2, 3]); +$hit = $b->has(2); +$miss = $b->has(9); diff --git a/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php b/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php new file mode 100644 index 0000000..13e99c0 --- /dev/null +++ b/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php @@ -0,0 +1,25 @@ +targetDir . '/Use.php'; + + Assert::assertTrue($hit); + Assert::assertFalse($miss); + + $containsMembers = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'contains_'), + )); + Assert::assertCount(1, $containsMembers, 'the erasure-lowered member is reused, never duplicated'); +}; diff --git a/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp b/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp new file mode 100644 index 0000000..01e9580 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp @@ -0,0 +1,30 @@ + +{ + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +class Holder +{ + /** @return array */ + public function m(Box $b, T $v): array + { + return $b->dup::($v); + } +} + +$h = new Holder::(); +$h->m(new Box::(), 1); diff --git a/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp new file mode 100644 index 0000000..e18b66e --- /dev/null +++ b/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp @@ -0,0 +1,38 @@ +dup::` +// threads Holder's concrete arguments through the extends chain to Base's parameters +// and lands the member on the HOLDER specialization itself — never on the shared Base +// template, whose mid-loop mutation would be order-dependent (an earlier-cloned +// Base spec must not grow an int member). +class Base +{ + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->viaThis(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php new file mode 100644 index 0000000..289aa9f --- /dev/null +++ b/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php @@ -0,0 +1,31 @@ + spec carries no int member. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + $holderDup = new ReflectionMethod($h, array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + ))[0]); + Assert::assertSame(get_class($h), $holderDup->getDeclaringClass()->getName(), 'member declared on the Holder spec itself'); + + $baseDup = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + Assert::assertSame([], $baseDup, 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp new file mode 100644 index 0000000..8902932 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp @@ -0,0 +1,53 @@ + */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +// Instance method turbofish grounded by the enclosing class type parameter, both +// receiver shapes: `$this->dup::` dispatches to a member grounded onto the +// specialization itself (once per spec, despite two call sites), and `$m->dup::` +// on a non-generic receiver grounds onto Maker like any external target. +final class Holder +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function viaThisAgain(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function viaObj(Maker $m, T $v): array + { + return $m->dup::($v); + } + + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r1 = $h->viaThis(1); +$r2 = $h->viaThisAgain(2); +$r3 = $h->viaObj(new Maker(), 3); + +$s = new Holder::(); +$r4 = $s->viaThis('a'); diff --git a/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php new file mode 100644 index 0000000..601fe29 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php @@ -0,0 +1,38 @@ +dup::` executes + * against the member grounded onto the specialization (exactly one per spec, two call + * sites), and `$m->dup::` against the member grounded onto the non-generic Maker. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([1, 1], $r1); + Assert::assertSame([2, 2], $r2); + Assert::assertSame([3, 3], $r3); + Assert::assertSame(['a', 'a'], $r4); + + $dupMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + Assert::assertCount(1, $dupMembers, 'two $this call sites, one grounded member per spec'); + + $makerDup = array_values(array_filter( + get_class_methods(\App\InstanceTurbofish\Maker::class), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + // Grounding is per-spec, not per-executed-path: BOTH specializations ground their + // whole body, so Maker carries dup AND dup — one per argument tuple, + // deduped across specs (never one per call site). + Assert::assertCount(2, $makerDup, 'one grounded member per unique argument tuple on the receiver class'); +}; diff --git a/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp new file mode 100644 index 0000000..739c685 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php new file mode 100644 index 0000000..68bc5b7 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php @@ -0,0 +1,23 @@ +targetDir . '/Use.php'; + + Assert::assertSame([9, 9], $r); + + $grounded = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'a_T_') || str_starts_with($m, 'b_T_'), + )); + Assert::assertCount(2, $grounded, 'both hops grounded onto the spec'); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp b/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp new file mode 100644 index 0000000..2ce562b --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp @@ -0,0 +1,14 @@ + */ + public static function wrap(X $value): array + { + return [$value]; + } +} diff --git a/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp new file mode 100644 index 0000000..07b8ddf --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp @@ -0,0 +1,60 @@ +` +// and `Maker::wrap::` are abstract inside the `Box` template and only become +// concrete when `Box` / `Box` specialize. Each specialization grounds and +// dispatches them: the own-template member `gen_T_` lands on the specialization +// itself (dispatched via `self::`, appended once per spec even with two call sites), +// and the shared non-generic target gets one `wrap_T_` per unique argument tuple +// regardless of how many generic classes forward to it. +class Box +{ + public function make(T $v): T + { + return self::gen::($v); + } + + public function makeAgain(T $v): T + { + // Same target + same grounded args as make(): per-spec dedup must + // append a single member, not one per call site. + return self::gen::($v); + } + + /** @return array */ + public function viaMaker(T $v): array + { + return Maker::wrap::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +class CoBox +{ + /** @return array */ + public function viaMaker(T $v): array + { + // Second generic class forwarding the same argument tuple to Maker: + // the shared wrap_T_ member must be appended exactly once. + return Maker::wrap::($v); + } +} + +$b = new Box::(); +$r1 = $b->make(5); +$r2 = $b->makeAgain(6); +$r3 = $b->viaMaker(7); + +$s = new Box::(); +$r4 = $s->make('hi'); + +$c = new CoBox::(); +$r5 = $c->viaMaker(8); diff --git a/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php new file mode 100644 index 0000000..bfbd9d6 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php @@ -0,0 +1,40 @@ +::make(5)` dispatches to the spec's own + * `gen_T_` member, `viaMaker` to the shared `Maker::wrap_T_` — and the + * dedup invariants hold at runtime: exactly one gen member per specialization (two + * call sites), exactly two wrap members on Maker (one per unique argument tuple, + * shared across the two forwarding generic classes). + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Maker.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $r1); + Assert::assertSame(6, $r2); + Assert::assertSame([7], $r3); + Assert::assertSame('hi', $r4); + Assert::assertSame([8], $r5); + + $genMembers = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )); + Assert::assertCount(1, $genMembers, 'two call sites, one appended gen member per spec'); + + $wrapMembers = array_values(array_filter( + get_class_methods(\App\MethodTurbofish\Maker::class), + static fn (string $m): bool => str_starts_with($m, 'wrap_T_'), + )); + Assert::assertCount(2, $wrapMembers, 'one wrap member per unique argument tuple (int, string)'); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php b/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php new file mode 100644 index 0000000..edeb302 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php @@ -0,0 +1,34 @@ +` +// and `Maker::wrap::` are abstract inside the `Box` template and only become +// concrete when `Box` / `Box` specialize. Each specialization grounds and +// dispatches them: the own-template member `gen_T_` lands on the specialization +// itself (dispatched via `self::`, appended once per spec even with two call sites), +// and the shared non-generic target gets one `wrap_T_` per unique argument tuple +// regardless of how many generic classes forward to it. +class T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8 implements \App\MethodTurbofish\Box +{ + public function make(int $v): int + { + return self::gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + public function makeAgain(int $v): int + { + // Same target + same grounded args as make(): per-spec dedup must + // append a single member, not one per call site. + return self::gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + /** @return array */ + public function viaMaker(int $v): array + { + return \App\MethodTurbofish\Maker::wrap_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + public static function gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int + { + return $x; + } +} diff --git a/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp new file mode 100644 index 0000000..0858b1e --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp @@ -0,0 +1,38 @@ + +{ + public function __construct( + public readonly P $first, + public readonly P $second, + ) { + } +} + +final class Maker +{ + // `Pair` is abstract on this template: no source-visible site instantiates + // Pair concretely. `Pair` first exists inside the `twin_T_` member + // that post-specialization grounding appends onto Maker — its instantiation must + // be collected from that appended body, or the emitted call references a + // generated class that was never specialized. + public static function twin(X $v): Pair + { + return new Pair::($v, $v); + } +} + +final class Holder +{ + public function make(T $v): mixed + { + return Maker::twin::($v); + } +} + +$h = new Holder::(); +$p = $h->make(9); diff --git a/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php b/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php new file mode 100644 index 0000000..4643eb1 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php @@ -0,0 +1,27 @@ +` inside `twin_T_`) + * was collected into the fixed point — the Pair specialization exists, loads, and + * carries the forwarded values. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $pairIntFqn = Registry::generatedFqn( + 'App\\TurbofishDiscovery\\Pair', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertInstanceOf($pairIntFqn, $p); + Assert::assertSame(9, $p->first); + Assert::assertSame(9, $p->second); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp deleted file mode 100644 index b9f96cb..0000000 --- a/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp +++ /dev/null @@ -1,25 +0,0 @@ -` -// depends on `Box`'s `T`. The class specializes in a phase with no generic-method-call -// rewriting, so `gen` is stripped and the turbofish dropped — the emitted specialization -// calls an undefined `self::gen()`. Rejected before that fatal-able code is emitted. -class Box -{ - public function make(T $v): T - { - return self::gen::($v); - } - - public static function gen(U $x): U - { - return $x; - } -} - -$b = new Box::(); -$b->make(5); diff --git a/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp new file mode 100644 index 0000000..8bf2031 --- /dev/null +++ b/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp @@ -0,0 +1,27 @@ + +{ + public static function gen(U $x): U + { + return $x; + } +} + +class Kid extends Base +{ + public function m(T $v): T + { + return parent::gen::($v); + } +} + +$k = new Kid::(); +$k->m(1); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp new file mode 100644 index 0000000..493bfbe --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -0,0 +1,46 @@ +` inside the +// Holder spec threads Holder's concrete arguments through the extends chain to Base's +// parameters and lands the member on the HOLDER specialization — mirroring the +// instance-call rule, never mutating the shared Base template. +class Base +{ + /** @return array */ + public static function gen(U $x): array + { + // A further hop DECLARED ON THE ANCESTOR: the grounded member lives on the + // Holder spec, so its drained body re-grounds `self::genB::` there too — + // the ancestor chain grounds hop by hop, not just one level. + return self::genB::($x); + } + + /** @return array */ + public static function genB(V $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function go(T $v): array + { + return self::gen::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->go(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php new file mode 100644 index 0000000..f458201 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -0,0 +1,38 @@ + spec grew nothing. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + // Both hops of the ancestor-declared chain (`gen` → `genB`) ground onto the + // Holder spec itself. + $genMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), + )); + Assert::assertCount(2, $genMembers); + foreach ($genMembers as $member) { + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $member))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + } + Assert::assertSame([], array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), + )), 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp new file mode 100644 index 0000000..a8db899 --- /dev/null +++ b/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp @@ -0,0 +1,25 @@ + +{ + public function m(T $v): T + { + return static::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp new file mode 100644 index 0000000..d149086 --- /dev/null +++ b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp new file mode 100644 index 0000000..0de306d --- /dev/null +++ b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/compile/generic_function_forward_chain/source/Use.xphp b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp new file mode 100644 index 0000000..3a1058a --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp @@ -0,0 +1,40 @@ +` → `mid::` → `identity::`), so the append-drain must keep grounding +// until the chain bottoms out. +function identity(U $x): U +{ + return $x; +} + +function mid(V $x): V +{ + return identity::($x); +} + +function wrap(T $v): T +{ + return mid::($v); +} + +// Same-args mutual recursion: ping

forwards to pong::

, whose body forwards back +// to ping::

. Compile-time termination rides the specialization dedup (the second +// visit finds the mangled key already generated and only rewrites the call); runtime +// termination rides the counter. +function ping

(P $v, int $n): P +{ + return $n <= 0 ? $v : pong::

($v, $n - 1); +} + +function pong(Q $v, int $n): Q +{ + return $n <= 0 ? $v : ping::($v, $n - 1); +} + +$a = wrap::(7); +$b = ping::('x', 3); diff --git a/test/fixture/compile/generic_function_forward_chain/verify/runtime.php b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php new file mode 100644 index 0000000..9e0bdbc --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php @@ -0,0 +1,38 @@ +` → `mid` → `identity`) and the same-args mutual-recursion pair + * (`ping` ↔ `pong`) both execute against the emitted specializations. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + foreach (['wrap', 'mid', 'identity'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $intHash), + $fn . ' specialization is declared', + ); + } + $wrapInt = 'App\\ForwardChain\\wrap_T_' . $intHash; + Assert::assertSame(7, $wrapInt(7)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + foreach (['ping', 'pong'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $stringHash), + $fn . ' specialization is declared', + ); + } + $pingString = 'App\\ForwardChain\\ping_T_' . $stringHash; + Assert::assertSame('x', $pingString('x', 3)); +}; diff --git a/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp new file mode 100644 index 0000000..8c44bac --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp @@ -0,0 +1,21 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp new file mode 100644 index 0000000..fd3f960 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_named_forward/source/Use.xphp b/test/fixture/compile/generic_function_named_forward/source/Use.xphp new file mode 100644 index 0000000..95ef2b4 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_named_forward/verify/runtime.php b/test/fixture/compile/generic_function_named_forward/verify/runtime.php new file mode 100644 index 0000000..b4f5737 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/runtime.php @@ -0,0 +1,39 @@ +` + * dispatches its forwarded `identity::` call to a real `identity_T_` + * specialization — the emitted chain executes end to end and returns the value + * through both hops. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + * Free functions aren't autoloadable in PHP, so require the emitted Use.php explicitly + * to bring both specialized declarations into scope (its top-level driver statements + * run too — they exercise the same calls). + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $wrapInt = 'App\\NamedForward\\wrap_T_' . $intHash; + $identityInt = 'App\\NamedForward\\identity_T_' . $intHash; + Assert::assertTrue(function_exists($wrapInt), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityInt), 'the forwarded identity specialization is declared'); + Assert::assertSame(3, $wrapInt(3)); + Assert::assertSame(41, $identityInt(41)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $wrapString = 'App\\NamedForward\\wrap_T_' . $stringHash; + $identityString = 'App\\NamedForward\\identity_T_' . $stringHash; + Assert::assertTrue(function_exists($wrapString), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityString), 'the forwarded identity specialization is declared'); + Assert::assertSame('hi', $wrapString('hi')); +}; diff --git a/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php new file mode 100644 index 0000000..fa817f7 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php @@ -0,0 +1,33 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int +{ + return $x; +} +// A named generic free function forwarding a type argument grounded by the enclosing +// function's type parameter: `identity::($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_473287f8298dba7163a897908958f7c0eae733e25d2e027992ea2edc9bed2fa8(string $x): string +{ + return $x; +} diff --git a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp b/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp deleted file mode 100644 index c3b41a2..0000000 --- a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp +++ /dev/null @@ -1,22 +0,0 @@ -($v)` depends on `wrap`'s `T`, which is not -// concrete here. The named-call turbofish is not specialized (its args are non-concrete), -// so the emitted `wrap_T_` calls `identity()` with the type parameter still leaked. -// Rejected by the emitted-marker backstop before that fatal-able code is written. -function identity(U $x): U -{ - return $x; -} - -function wrap(T $v): T -{ - return identity::($v); -} - -wrap::(3);