diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c2a3fa..b6d67b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Type-argument inference (optional turbofish).** A generic call or `new` whose + type parameters are determined by the argument values no longer needs the `::<>` + turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers + `new Box::`, `$factory->make($p)` and `$box->put($this->item)` infer + from the argument's type. Works for free functions, static and instance methods, + and class instantiation. An inferred call compiles to exactly the specialization + the turbofish would have selected — the type arguments are unified from the + arguments' static types and dispatched through the identical path, so bounds, + variance, mangling, and check/compile parity are unchanged. Argument types are + read conservatively (literals, `new`, `$this` properties, and non-reassigned + typed parameters); where they don't determine the type — a type parameter only in + the return type, an unknown argument type, or conflicting arguments — the explicit + turbofish is still required and omitting it remains the same `xphp.missing_type_argument` + error. Generic closure calls (`$f($x)`) and `T[]`-typed parameters are not yet + inference sources. See [turbofish → inference](docs/syntax/turbofish.md#type-argument-inference). - **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: diff --git a/docs/caveats.md b/docs/caveats.md index b565be60..a261238e 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -7,6 +7,88 @@ each with the underlying reason and the workaround. Pages in the [syntax tour](syntax/) link back to specific sections here using anchor links — search this page for the same heading text. +## Type-argument inference is partial + +xphp infers a generic call's or `new`'s type arguments from the values you +pass, so the `::<>` turbofish is optional where the arguments determine the +type. But inference reads argument types conservatively — deliberately, so it +never emits a specialization the runtime value can't match — and where it +can't see a concrete type, you still write the turbofish. + +### ❌ What isn't inferred + +```php +function first(): T { /* ... */ } // T is only in the return type +$x = first(); // ✗ nothing to infer from — needs first::() + +function pair(T $a, T $b): array { /* ... */ } +$p = pair(1, 'x'); // ✗ int vs string disagree — needs pair::<...>() + +$val = $repo->find(); // a scalar-returning call assigned to a local +$b = new Box($val); // ✗ local-from-call isn't tracked — needs new Box::() + +function f(Fruit $x): void { + $x = pickAnother(); // $x reassigned... + $b = new Box($x); // ✗ reassigned param isn't trusted — needs the turbofish +} + +$g = function(T $x): T { return $x; }; +$g(5); // ✗ generic *closure* calls aren't inferred (deferred) +``` + +### ✅ What is inferred + +```php +identity(5); // ✓ T = int, from the literal +wrap(new Plastic()); // ✓ T = Plastic, from the `new` +Factory::make($p); // ✓ from $p's declared (class) type +$box->put($this->item); // ✓ from the declared property type +wrap($factory->make()); // ✓ from make()'s class return type (call path) +new Box(5); // ✓ T = int +new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new` +``` + +Inference sources differ slightly between the two paths, because a **call** +reuses the monomorphizer's receiver/flow tracking while **`new`** runs a +lighter standalone pass: + +- **Calls** infer from: literals, `new X(...)`, `$this->prop` (declared type), + a plain parameter, a local whose type is statically tracked (assigned from a + `new` or a class-returning call), and a call whose declared return type is a + determinable class. +- **`new`** infers from the conservative set only: literals, `new X(...)`, + `$this->prop`, and a non-reassigned typed parameter — not locals or call + returns. + +In both, a reassigned parameter, a *scalar*-returning-call value held in a +local, a union-typed value, or a value typed by a still-abstract type parameter +yields no inference. + +One conservative edge: inference is skipped when an argument's *simple* type +name coincides with an in-scope type parameter — e.g. a class imported as +`use Other\U as U` (or a same-named `U` in the current namespace) passed inside +`f(...)`. The name is treated as the type parameter (which shadows it), so +the call falls back. It never mis-infers — write the explicit turbofish there. + +### Why + +Monomorphization needs the *concrete* type to pick a specialization, and an +inferred call must compile to exactly what the turbofish would have. So +inference only fires when it can prove the concrete type from the argument +itself: it derives the type arguments by unifying each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses (same bounds, variance, and mangling). A value +whose type it can't prove statically — or can't prove *soundly*, like a +reassigned parameter — is left alone rather than guessed, because a wrong guess +would emit a specialization the runtime value fails to satisfy. + +### ✅ Workaround + +Write the explicit turbofish (`identity::(5)`, `new Box::($val)`) +wherever inference can't see the type. It's always accepted, and an inferred +call is identical to the turbofished one — so adding a turbofish never changes +behavior, only makes the type explicit. + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work diff --git a/docs/errors.md b/docs/errors.md index 4230b83b..8e333072 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -37,7 +37,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: |------|---------| | `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | | `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | -| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box` has a required parameter, instead of `new Box::(...)`): the type argument takes no inference, so it must be supplied explicitly | +| `xphp.missing_type_argument` | a required type argument was omitted, has no default, and **could not be inferred from the call/constructor arguments** — e.g. a type parameter used only in the return type, an argument whose static type isn't known, or arguments that disagree. A turbofish-less call (`$x->pick('a')`) or bare `new` (`new Box(...)`) is fine when the arguments determine the type; when they don't, supply an explicit turbofish (`$x->pick::('a')`, `new Box::(...)`). See [turbofish → inference](syntax/turbofish.md#type-argument-inference) | | `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | | `xphp.variance_position` | an `out T` / `in T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index af5b0b11..0e6404ff 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -25,10 +25,10 @@ than erasure can. | Feature | xphp | RFC | TS | Kotlin | Rust | |------------------------------------------|-------------------------|------------------|------------------|---------------|-----------------------| | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic functions / methods | ⚠️ (no inference; can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | +| Generic functions / methods | ⚠️ (can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | | Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ | | Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ❌ (only untyped `callable` / `\Closure`; noted as future work) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | -| Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | +| Type-argument inference (call without `::<>`) | ⚠️ (inferred from the arguments for calls and `new` where they determine the type; otherwise the explicit turbofish is still required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | @@ -54,18 +54,29 @@ mark features that simply can't exist under bound erasure: there are no specialized classes at runtime, so subtype edges, reified-T operations, and a wildcard sigil all lose their meaning. -**Type-argument inference.** No xphp generic call infers its type -arguments from the values passed — you always write the turbofish: -`identity::($x)`, `new Box::()`. Omitting it is a compile -error (`xphp.missing_type_argument`), because monomorphization needs -the concrete type to pick a specialization. TypeScript, Kotlin, and -Rust all infer instead. Rust is the closest comparison: xphp borrows -its `::<>` turbofish spelling exactly, but where Rust infers by -default and reaches for the turbofish only to disambiguate, xphp -makes it the only spelling. The bound-erasure RFC has no inference -either, yet diverges from xphp in the other direction — there the -turbofish is *optional*: omit it and the call runs unvalidated with -erased-to-`mixed` semantics rather than failing to compile. +**Type-argument inference.** xphp infers the type arguments from the +values passed when they determine the type, so the turbofish is +optional there: `identity(5)` infers `identity::`, `new Box($product)` +infers `new Box::`, `$factory->make($p)` infers from `$p`'s +type. An inferred call compiles to exactly the specialization the +turbofish would have selected — inference only writes the turbofish for +you, so bounds, variance, and mangling are unchanged. When the arguments +*don't* determine the type — a type parameter used only in the return +type, an argument whose static type isn't known, or two arguments that +disagree — you still write the turbofish, and omitting it is the same +compile error as before (`xphp.missing_type_argument`). Argument types are +read conservatively (literals, `new`, `$this` properties, and non-reassigned +typed parameters); generic *closure* calls (`$f($x)`) and `T[]`-typed +parameters are not yet inference sources and keep the explicit turbofish. +See [caveats](../caveats.md#type-argument-inference-is-partial). + +This is the same `::<>` turbofish Rust uses, and xphp now works like Rust +in spirit: infer by default, reach for the turbofish to disambiguate or +where inference can't see the type. TypeScript and Kotlin infer too. The +bound-erasure RFC has no inference, and diverges in the other direction — +there the turbofish is *optional* in a different sense: omit it and the +call runs unvalidated with erased-to-`mixed` semantics rather than +inferring or failing to compile. ## Where the monomorphic and erasure paths diverge @@ -144,10 +155,11 @@ reproduction and workaround) in [caveats](../caveats.md). parameter can't be defaulted or untyped ([caveat](../caveats.md#closure-signature-types-only-in-parameter-return-and-property-slots)). - **Generic functions / methods.** The base feature is solid; *composition* - is where the gaps are. There's no type-argument inference — the turbofish - is mandatory (see the grid row). And a turbofish grounded by an enclosing - type parameter can't forward a *method-level* parameter, target a - *different* generic template, or use the `static::`/`parent::` spellings + is where the gaps are. Type arguments are inferred where the call arguments + determine them, and otherwise the turbofish is required (see the grid row). + And a turbofish grounded by an enclosing type parameter can't forward a + *method-level* parameter, target a *different* generic template, or use the + `static::`/`parent::` spellings ([caveat](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)). Receiver-type tracking also gives up across branches that disagree on the type, and on a local assigned from a free function diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 36a5bb85..f80cf951 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -83,16 +83,51 @@ $id('T_', 42); template is all-defaulted. - Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted class templates — see [defaults](defaults.md). -- **The type argument is not inferred from the call arguments.** A - turbofish-less call (`$x->pick('a')` instead of - `$x->pick::('a')`) is a compile error - (`xphp.missing_type_argument`), not a silent skip — `xphp check` - catches a forgotten turbofish at build time rather than letting it - fatal at runtime. A generic **method** whose type parameters are all - defaulted may still be called bare; a named generic **function** or - **closure** has no bare or empty-turbofish form, so it always needs - an explicit turbofish. (A first-class callable `pick(...)` creates a - closure rather than calling, and is left alone.) +- **The turbofish is optional where the arguments determine the type.** + A bare call or `new` whose type parameters are fixed by the argument + values is inferred — `$x->pick('a')` infers `$x->pick::('a')`, + `new Box(5)` infers `new Box::(5)` — and compiles to exactly the + specialization the turbofish would have selected (see + [inference](#type-argument-inference), below). When the arguments *don't* + determine the type — a type parameter only in the return type, an argument + whose static type isn't known, or arguments that disagree — a turbofish-less + call is a compile error (`xphp.missing_type_argument`), not a silent skip: + `xphp check` catches it at build time rather than letting it fatal at + runtime. A generic **method** whose type parameters are all defaulted may + still be called bare; a generic **closure** call (`$f($x)`) is not inferred + and always needs an explicit turbofish. (A first-class callable `pick(...)` + creates a closure rather than calling, and is left alone.) + +## Type-argument inference + +Where the arguments determine the type parameters, you can omit the turbofish +and xphp infers it: + +```php +$r = identity(5); // identity:: +$b = new Box(new Plastic()); // new Box:: +$m = Factory::make($product); // from $product's declared type +$d = $bag->put($this->item); // from the declared property type +``` + +Inference derives the type arguments by matching each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses — so an inferred call is byte-for-byte the same +specialization, with the same bound and variance checks. It never *weakens* +anything: adding a turbofish to an inferred call can only make the type +explicit, never change behavior. + +Argument types are read from: literals, `new X(...)`, `$this->prop` (declared +type), and a plain parameter with a concrete declared type that isn't reassigned. +A **call** additionally infers from a statically-tracked local (assigned from a +`new` or a class-returning call) and from a call whose declared return type is a +determinable class, because the call path reuses the monomorphizer's receiver/flow +tracking; **`new`** inference is limited to the conservative set (no locals or call +returns). A reassigned parameter, a scalar-returning-call value, a union-typed +value, or a value typed by a still-abstract type parameter is never an inference +source, and such a site keeps the explicit turbofish. Generic **closure** calls +(`$f($x)`) and `T[]`-typed parameters are not yet inferred either. See +[caveats](../caveats.md#type-argument-inference-is-partial). ## Receiver-type analysis (instance methods) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 1ea37ce7..23e8e60f 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -86,17 +86,28 @@ public function compile( // AST in compile-mode, so this must precede it). UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy); - $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); - $methodCompiler->process($astPerFile); - // Phase 1b.i: collect class definitions across every source file. Splitting // definitions ahead of instantiations gives bare-`new Foo;` synthesis (added // in 1b.ii) a complete template registry so it can recognize Foo as an - // all-defaulted template regardless of the file-walk order. + // all-defaulted template regardless of the file-walk order. Collected BEFORE the + // method compiler runs so the type-argument inference pass below has the full + // template registry AND sees the original (un-stripped, un-appended) user ASTs — + // the same shape `check()` runs it against, keeping the two modes in parity. foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new`: infer a bare `new Box($x)`'s type arguments from its + // constructor arguments and annotate it, so the instantiation collector + call-site + // rewriter treat it as an explicit turbofish. A `new` it can't resolve is left bare + // (all-defaults synthesis / missing-type-argument error). Runs before `process` so it + // never sees appended specializations (which `check` can't), and before + // collectInstantiations so the annotation is picked up. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); + + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); + $methodCompiler->process($astPerFile); + // Phase 1b.ii: validate defaults-against-bounds at the source level (so a // bad declaration like `class Box` fails BEFORE any // padded instantiation is recorded), then collect instantiations -- including @@ -501,6 +512,10 @@ public function check(FilepathArray $sources): DiagnosticCollector foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new` (see compile()): infer bare `new` type arguments before + // instantiations are collected. Same pipeline position as compile — after definitions, + // before collectInstantiations — so check and compile infer identically. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); $registry->validateVariancePositions(); $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); diff --git a/src/Transpiler/Monomorphize/ExpressionTyper.php b/src/Transpiler/Monomorphize/ExpressionTyper.php new file mode 100644 index 00000000..80095a9d --- /dev/null +++ b/src/Transpiler/Monomorphize/ExpressionTyper.php @@ -0,0 +1,29 @@ +prop`, or a call return — which only the + * monomorphizer's receiver/scope tracker can answer, and only inside a method body. + * + * Returning null means "cannot determine a concrete type here"; the inference driver then leaves + * that argument's parameter unconstrained (which, if it was the only witness for a required type + * parameter, makes the whole inference fall back to today's explicit-turbofish requirement). An + * implementation must never invent a type it cannot prove — an unknown expression is null, never a + * guess. + */ +interface ExpressionTyper +{ + /** The concrete static type of `$expr`, or null when it cannot be determined. */ + public function typeOf(Expr $expr): ?TypeRef; +} diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7d7e9da4..3f210594 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -540,7 +540,7 @@ private function rewriteCallSites( // @infection-ignore-all — see rationale above the indexTemplates visitor: defensive // guards and call-shape mutations are masked by the surrounding pipeline's // type-strict invariants. End-to-end coverage from GenericMethodIntegrationTest. - $visitor = new class($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract { + $visitor = new class($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract implements ExpressionTyper { private string $currentNamespace = ''; private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ @@ -646,6 +646,18 @@ private function rewriteCallSites( * @var array */ private array $currentScopeClosureTemplates = []; + /** + * In-scope generic type-parameter names of the enclosing function/method/closure scopes, + * accumulated through nesting. A call/`new` argument whose declared type IS one of these + * is abstract here (it's a type parameter, not a concrete class), so it must NOT seed + * inference — otherwise a bare `identity($x)` inside `outer(U $x)` would infer + * `identity::` and emit a specialization referencing the non-existent class `U`. + * Class-level type parameters are read dynamically from {@see $currentClassFqn} in + * {@see typeOf}. Mirrors {@see NewInferencePass::enclosingTypeParamNames}. + * + * @var array + */ + private array $currentScopeTypeParamNames = []; /** * Parallel to `currentScopeClosureTemplates`: the Assign node and * lexical-scope info that introduced each generic anonymous template. @@ -696,7 +708,7 @@ private function rewriteCallSites( * are snapshotted too so a generic closure assigned in one scope doesn't leak * into a sibling scope where the same variable names an unrelated callable. * - * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, typeParamNames: array, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> */ private array $scopeSnapshots = []; /** @@ -754,8 +766,16 @@ public function __construct( private readonly ?ClosureConformanceValidator $closureValidator, ) { $this->nsContext = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); } + /** + * Types literals and `new` for {@see typeOf} — the context-free half of argument typing; + * the flow-dependent half (variables, `$this->prop`, call returns) is answered by this + * visitor's own receiver/scope resolvers. + */ + private readonly LiteralTyper $literalTyper; + /** * Reset the visitor's lexical state for one drained (detached) specialized * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, @@ -777,6 +797,7 @@ public function primeDrainScope(?string $classFqn, string $namespace): void $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->scopeSnapshots = []; $this->currentScopeClosureTemplates = []; @@ -837,11 +858,13 @@ public function enterNode(Node $node): null|int $parentLocals = $this->currentScopeLocalTypes; $parentParamArgs = $this->currentScopeParamTypeArgs; $parentLocalArgs = $this->currentScopeLocalTypeArgs; + $parentTypeParamNames = $this->currentScopeTypeParamNames; $this->scopeSnapshots[] = [ 'params' => $parentParams, 'locals' => $parentLocals, 'paramArgs' => $parentParamArgs, 'localArgs' => $parentLocalArgs, + 'typeParamNames' => $parentTypeParamNames, 'branches' => $this->branchSnapshots, // Closure-template tracking is per-scope too: a generic closure assigned to `$f` // in one function must NOT leak into a sibling scope where `$f` is an unrelated @@ -857,6 +880,18 @@ public function enterNode(Node $node): null|int $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; + // Type parameters accumulate through nesting: this scope sees the enclosing + // scopes' type params plus its own. Used to keep a type-param-typed argument + // from seeding inference (it's abstract here, not a concrete class). + $this->currentScopeTypeParamNames = $parentTypeParamNames; + $ownTypeParams = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($ownTypeParams)) { + /** @var list $ownTypeParams */ + foreach ($ownTypeParams as $ownTypeParam) { + $this->currentScopeTypeParamNames[$ownTypeParam->name] = true; + } + } + // For closures: `use ($x)` explicitly imports outer variables. // Copy each imported name's type from the parent scope so the // closure body can specialize `$x->m::(...)` correctly. @@ -1145,6 +1180,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = $snapshot['locals']; $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; + $this->currentScopeTypeParamNames = $snapshot['typeParamNames']; $this->branchSnapshots = $snapshot['branches']; $this->currentScopeClosureTemplates = $snapshot['closureTemplates']; $this->currentScopeClosureContexts = $snapshot['closureContexts']; @@ -1157,6 +1193,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; @@ -1502,14 +1539,13 @@ private function rewriteStaticCall(StaticCall $node): ?Node if ($node->isFirstClassCallable()) { return null; } - // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an - // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A - // method generic can't infer its type argument from the call args, so a bare call to a - // non-all-default generic is an error — not a silent skip that emits a call to the - // stripped method and fatals at runtime. - $args = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty above). */ $location = new SourceLocation($this->currentFile, $node->getStartLine()); $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); if (!self::allConcrete($padded) || count($params) !== count($padded)) { @@ -1696,14 +1732,13 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): if ($node->isFirstClassCallable()) { return null; } - // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an - // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A - // method generic can't infer its type argument from the call args, so a bare call to a - // non-all-default generic is an error — not a silent skip that emits a call to the - // stripped method and fatals at runtime. - $args = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty above). */ $location = new SourceLocation($this->currentFile, $node->getStartLine()); $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); // Arity first: in `check` mode padArgsWithDefaults collects an arity diagnostic and @@ -2166,8 +2201,8 @@ private function reportUnspecializableSelfCall(string $methodName, SourceLocatio private function reportMissingTurbofishArguments(string $label, SourceLocation $location): void { $message = sprintf( - 'Generic call `%s(...)` is missing its type arguments: a generic function or closure ' - . 'takes no inference, so it must be called with an explicit turbofish `%s::<...>(...)`.', + 'Generic call `%s(...)` is missing its type arguments: they could not be inferred ' + . 'from the call arguments, so call it with an explicit turbofish `%s::<...>(...)`.', $label, $label, ); @@ -2639,6 +2674,197 @@ private static function boundHasUngroundedLeaf(BoundExpr $bound): bool return false; } + /** + * Infer a bare generic call's type arguments from its ordinary arguments' static types, + * so the turbofish is optional wherever the arguments determine it. Returns the inferred + * concrete arguments (ready to dispatch exactly as an explicit turbofish would), or null + * to fall back to today's missing-turbofish handling. Inference is skipped without a type + * hierarchy (nothing to ground subtypes or run the identical bound checks against), + * matching how the seams below guard their bound checks. + * + * @param list $typeParams the callee's generic parameters + * @param array $valueParams the callee's value parameters (template AST) + * @param array $args the call-site arguments + * @return list|null + */ + private function inferCallTypeArgs(array $typeParams, array $valueParams, array $args): ?array + { + if ($this->hierarchy === null) { + return null; + } + return (new TypeInference($this->hierarchy))->infer($typeParams, $valueParams, $args, $this); + } + + /** + * The concrete static type of an argument expression, for {@see TypeInference}. Literals + * and `new` are delegated to the context-free {@see LiteralTyper}; a variable, `$this` + * property, or call return is answered from this visitor's own receiver/scope tracking. + * Anything not statically determinable — an untyped local, a scalar flow value, an + * abstract (still-templated) type — is null, so its parameter is left unconstrained. + */ + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $fqn = $this->currentScopeParamTypes[$expr->name] + ?? $this->currentScopeLocalTypes[$expr->name] + ?? null; + if ($fqn === null || $this->isInScopeTypeParam($fqn)) { + // Unknown, or the variable's declared type is an enclosing type parameter — + // abstract here, so not a concrete inference source. + return null; + } + $args = $this->currentScopeParamTypeArgs[$expr->name] + ?? $this->currentScopeLocalTypeArgs[$expr->name] + ?? []; + return self::concreteOrNull(new TypeRef($fqn, $args)); + } + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $this->typeOfThisProperty($expr->name->toString()); + } + if ($expr instanceof MethodCall || $expr instanceof NullsafeMethodCall || $expr instanceof StaticCall) { + $return = $this->resolveCallReturn($expr); + if ($return === null || $this->isInScopeTypeParam($return[0])) { + return null; + } + return self::concreteOrNull(new TypeRef($return[0], $return[1])); + } + return null; + } + + /** + * Whether a resolved type name refers to a generic type parameter in scope — an enclosing + * function/method/closure parameter ({@see $currentScopeTypeParamNames}) or an enclosing + * class parameter. Such a name is abstract at this site (a type parameter shadows a + * same-named class), so a value of that type must not seed inference — otherwise a bare + * `identity($x)` inside `outer(U $x)` would infer `identity::` and emit a call to a + * non-existent class. Mirrors {@see NewInferencePass}'s use of paramTypeRef's type-param set. + */ + private function isInScopeTypeParam(string $fqn): bool + { + $short = self::lastSegment(ltrim($fqn, '\\')); + if (isset($this->currentScopeTypeParamNames[$short])) { + return true; + } + 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 */ + foreach ($classParams as $classParam) { + if ($classParam->name === $short) { + return true; + } + } + return false; + } + + /** + * The declared type of `$this->$propName` as a concrete TypeRef, or null when the class, + * property, or its type cannot be determined (an unknown class, a promoted-constructor or + * union-typed property, or a type that is not yet concrete in this template). + */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->currentClassFqn === null) { + return null; + } + $owner = $this->index->classLike($this->currentClassFqn); + if ($owner === null) { + return null; + } + foreach ($owner->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = $stmt->type; + if ($type instanceof NullableType) { + $type = $type->type; + } + if (!$type instanceof Name) { + return null; + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + $fqn = $this->resolveClassName($type); + if ($this->isInScopeTypeParam($fqn)) { + // A property typed by the class's own type parameter (`private T $value` + // in `Box`) is abstract here — not a concrete inference source. + return null; + } + return self::concreteOrNull(new TypeRef($fqn, $argRefs)); + } + } + return null; + } + + /** A type is a basis for inference only when fully concrete; an abstract one is null. */ + private static function concreteOrNull(TypeRef $ref): ?TypeRef + { + return $ref->isConcrete() ? $ref : null; + } + + /** + * Try to infer a bare free-function call's type arguments; on success, annotate the node + * as if the turbofish had been written — so {@see rewriteFuncCall} re-dispatches it down + * the identical explicit-turbofish path — and return true. Only free-function calls (a + * Name callee) are inferred; a bare generic *closure* call ($var) keeps the explicit- + * turbofish requirement (deferred). The inferred prefix is padded to full arity with the + * template's defaults so the annotation carries the exact tuple a turbofish would. + * + * @param list $typeParams + */ + private function tryInferFuncCall(FuncCall $node, array $typeParams): bool + { + if (!$node->name instanceof Name) { + return false; + } + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn === null) { + return false; + } + $template = $this->index->functionTemplate($fqn); + if ($template === null) { + return false; + } + $inferred = $this->inferCallTypeArgs($typeParams, $template->params, $node->args); + if ($inferred === null) { + return false; + } + // Free-function dispatch requires an exact-arity tuple (it does not pad), so fill any + // defaulted tail here. Inference only ever leaves a defaultable tail unbound, so this + // never reports — it yields the same complete tuple an explicit turbofish would. + $padded = Registry::padArgsWithDefaults( + $typeParams, + $inferred, + $fqn, + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + if (count($padded) !== count($typeParams) || !self::allConcrete($padded)) { + return false; + } + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $padded); + $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + return true; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -2653,6 +2879,12 @@ private function rewriteFuncCall(FuncCall $node): ?Node if (!$node->isFirstClassCallable()) { $bare = $this->resolveBareGenericCall($node); if ($bare !== null) { + // Optional turbofish: infer the type arguments from the call arguments + // and re-dispatch as if they had been written. Only when that fails is a + // bare generic call the missing-type-arguments error it is today. + if ($this->tryInferFuncCall($node, $bare[0])) { + return $this->rewriteFuncCall($node); + } $this->reportMissingTurbofishArguments( $bare[1], new SourceLocation($this->currentFile, $node->getStartLine()), diff --git a/src/Transpiler/Monomorphize/LiteralTyper.php b/src/Transpiler/Monomorphize/LiteralTyper.php new file mode 100644 index 00000000..f25e77f1 --- /dev/null +++ b/src/Transpiler/Monomorphize/LiteralTyper.php @@ -0,0 +1,76 @@ +prop`, a call return — is out of scope here and + * yields null; the monomorphizer's own receiver/scope tracker answers those (and typically composes + * with this typer, delegating literal/`new` shapes to it). A `new` whose type is not fully concrete + * (an un-turbofished generic construction, or an anonymous/dynamic class) also yields null: this + * typer never invents a type it cannot read directly and completely. + */ +final class LiteralTyper implements ExpressionTyper +{ + public function typeOf(Expr $expr): ?TypeRef + { + if ($expr instanceof Int_) { + return new TypeRef('int', isScalar: true); + } + if ($expr instanceof Float_) { + return new TypeRef('float', isScalar: true); + } + if ($expr instanceof String_) { + return new TypeRef('string', isScalar: true); + } + if ($expr instanceof ConstFetch) { + $name = strtolower($expr->name->toString()); + return $name === 'true' || $name === 'false' + ? new TypeRef('bool', isScalar: true) + : null; + } + if ($expr instanceof Array_) { + return new TypeRef('array', isScalar: true); + } + if ($expr instanceof New_) { + return self::typeOfNew($expr); + } + return null; + } + + /** + * The constructed type of a `new X(...)` expression: the class's resolved FQN plus any + * turbofish type arguments the parser attached (`new Box::()` → `Box`). Null for a + * dynamic (`new $class()`) or anonymous class, and for a construction that is not fully concrete + * (`new Box::()` inside a template, or a bare generic `new Box(...)` awaiting its own + * inference) — an abstract or unresolved type is no basis for inferring another. + */ + private static function typeOfNew(New_ $new): ?TypeRef + { + if (!$new->class instanceof Name) { + return null; + } + $resolved = $new->class->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + $args = $new->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + $ref = new TypeRef(is_string($resolved) ? $resolved : $new->class->toString(), $argRefs); + return $ref->isConcrete() ? $ref : null; + } +} diff --git a/src/Transpiler/Monomorphize/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php new file mode 100644 index 00000000..d13007d7 --- /dev/null +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -0,0 +1,332 @@ + the enclosing class stack, for `$this`-property typing */ + private array $classStack = []; + /** @var list> per-function scope: variable name => trustworthy concrete type */ + private array $scopes = []; + + public function __construct( + private readonly Registry $registry, + TypeHierarchy $hierarchy, + ) { + $this->ctx = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); + $this->inference = new TypeInference($hierarchy); + } + + /** @param array> $astPerFile */ + public function run(array $astPerFile): void + { + foreach ($astPerFile as $ast) { + $this->ctx = new NamespaceContext(); + $this->classStack = []; + $this->scopes = []; + $traverser = new NodeTraverser(); + $traverser->addVisitor($this); + $traverser->traverse($ast); + } + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->ctx->enterNamespace($node->name?->toString()); + } + if ($node instanceof Use_) { + $this->ctx->indexUse($node); + } elseif ($node instanceof GroupUse) { + $this->ctx->indexGroupUse($node); + } + if ($node instanceof ClassLike) { + $this->classStack[] = $node; + } + if ($node instanceof FunctionLike) { + $this->scopes[] = $this->scopeForFunction($node); + } + return null; + } + + public function leaveNode(Node $node): null + { + // Infer on leave (bottom-up): a nested `new` argument must be annotated with its own + // inferred type arguments BEFORE its enclosing `new` reads it, or `new Box(new Box(5))` + // would type the inner as the raw `Box` template and infer `Box` instead of + // `Box>` — a specialization the explicit turbofish would never produce. The + // namespace context, class stack, and scope are still in place here: those are popped + // only when the enclosing ClassLike/FunctionLike leaves, which is strictly later. + if ($node instanceof New_ + && $node->class instanceof Name + && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null + ) { + $this->tryInferNew($node->class, $node); + } + if ($node instanceof FunctionLike) { + array_pop($this->scopes); + } + if ($node instanceof ClassLike) { + array_pop($this->classStack); + } + return null; + } + + /** + * Infer and attach the type arguments of a bare `new`, or leave it untouched. The class must + * resolve to a generic template; a non-generic or unknown class is left for PHP / the bare-new + * synthesis to handle. + */ + private function tryInferNew(Name $class, New_ $node): void + { + $fqn = $this->ctx->resolveName($class); + $definition = $this->registry->definition($fqn); + if ($definition === null || $definition->typeParams === []) { + return; + } + $inferred = $this->inference->infer( + $definition->typeParams, + self::constructorParams($definition->templateAst), + $node->args, + $this, + ); + if ($inferred !== null) { + // Mirror synthesizeBareNewIfAllDefaults / an explicit turbofish: the collector records + // the instantiation off these two attributes, padding any defaulted tail itself. + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $inferred); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + } + } + + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $scope = $this->scopes === [] ? [] : $this->scopes[array_key_last($this->scopes)]; + return $scope[$expr->name] ?? null; + } + $propName = self::thisPropertyName($expr); + if ($propName !== null) { + return $this->typeOfThisProperty($propName); + } + return null; + } + + /** + * The property name of a `$this->prop` fetch, or null for any other expression. A defensive + * AST-shape guard: the `&&` chain narrows to exactly a plain `$this->name` fetch, excluding a + * `$other->prop`, a `$this->expr->prop`, a `$this->$dynamic`, or a method call. + */ + private static function thisPropertyName(Node\Expr $expr): ?string + { + // @infection-ignore-all -- each `&&` guards a distinct AST shape; flipping one to `||` + // either needs a receiver/name form that never appears as a plain property-fetch argument + // (a `$this->a->b` or variable-variable) or is observationally identical here. + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $expr->name->toString(); + } + return null; + } + + /** + * Build a function's variable-type scope: every parameter with a concrete declared type that is + * never reassigned in the body. A reassigned parameter is dropped — after `$x = …` it no longer + * holds its declared type, so inferring from that type would be unsound. + * + * @return array + */ + private function scopeForFunction(FunctionLike $fn): array + { + $typeParamNames = $this->enclosingTypeParamNames($fn); + $reassigned = self::reassignedNames($fn); + $scope = []; + foreach ($fn->getParams() as $param) { + // @infection-ignore-all LogicalOr -- defensive: a real parameter always has a Variable + // var with a string name (Error vars / expression names only arise on parse failures + // that never reach this pass), so both operands are always false and || vs && is + // observationally identical. + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $name = $param->var->name; + if (isset($reassigned[$name])) { + continue; + } + $type = TypeInference::paramTypeRef($param->type, $typeParamNames); + if ($type !== null && $type->isConcrete()) { + $scope[$name] = $type; + } + } + return $scope; + } + + /** The declared type of `$this->$propName`, or null when it is not a determinable concrete type. */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->classStack === []) { + return null; + } + $class = $this->classStack[array_key_last($this->classStack)]; + $typeParamNames = self::typeParamNamesOf($class); + foreach ($class->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = TypeInference::paramTypeRef($stmt->type, $typeParamNames); + return $type !== null && $type->isConcrete() ? $type : null; + } + } + return null; + } + + /** + * The type-parameter names in scope for a function: its enclosing class's parameters plus its + * own method/function-level generic parameters. A parameter or property typed by one of these + * is abstract here, so it never becomes a (spuriously concrete) inference source. + * + * @return array + */ + private function enclosingTypeParamNames(FunctionLike $fn): array + { + $names = $this->classStack === [] + ? [] + : self::typeParamNamesOf($this->classStack[array_key_last($this->classStack)]); + $methodParams = $fn->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($methodParams)) { + /** @var list $methodParams */ + foreach ($methodParams as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in paramTypeRef, so the stored value is immaterial. + $names[$param->name] = true; + } + } + return $names; + } + + /** @return array */ + private static function typeParamNamesOf(ClassLike $class): array + { + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $names = []; + if (is_array($params)) { + /** @var list $params */ + foreach ($params as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in TypeInference::paramTypeRef, so the stored value is immaterial. + $names[$param->name] = true; + } + } + return $names; + } + + /** + * The variable names assigned anywhere in a function body — the conservative "do not trust" + * set. Covers `=`, `=&`, compound-assign, and inc/dec; nested closures are included too, so a + * parameter a closure mutates by reference is (safely) not trusted. + * + * @return array + */ + private static function reassignedNames(FunctionLike $fn): array + { + $stmts = $fn->getStmts(); + // @infection-ignore-all ReturnRemoval -- a bodiless method (interface/abstract) has null + // stmts; the guard is for the type checker. Removing it is equivalent at runtime because + // NodeFinder::find(null) returns [] anyway (verified), yielding the same empty result. + if ($stmts === null) { + return []; + } + $targets = (new NodeFinder())->find($stmts, static fn (Node $n): bool => + $n instanceof Assign || $n instanceof AssignRef || $n instanceof AssignOp + || $n instanceof PreInc || $n instanceof PostInc + || $n instanceof PreDec || $n instanceof PostDec); + $names = []; + foreach ($targets as $target) { + /** @var Assign|AssignRef|AssignOp|PreInc|PostInc|PreDec|PostDec $target */ + $var = $target->var; + if ($var instanceof Variable && is_string($var->name)) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in scopeForFunction, so the stored value is immaterial. + $names[$var->name] = true; + } + } + return $names; + } + + /** + * The parameters of a template's `__construct`, or an empty list when it declares none — the + * shape inference unifies the constructor arguments against. + * + * @return array + */ + private static function constructorParams(ClassLike $template): array + { + foreach ($template->stmts as $stmt) { + if ($stmt instanceof ClassMethod && strtolower($stmt->name->toString()) === '__construct') { + return $stmt->params; + } + } + return []; + } +} diff --git a/src/Transpiler/Monomorphize/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php new file mode 100644 index 00000000..78f8515c --- /dev/null +++ b/src/Transpiler/Monomorphize/TypeInference.php @@ -0,0 +1,302 @@ +` turbofish becomes optional wherever the argument values determine it. + * + * The algorithm is the structural inverse of {@see Specializer::substituteTypeRef}: substitution + * takes a template type (`Box`) plus a binding (`T => int`) and produces a concrete type + * (`Box`); inference takes the template parameter type (`Box`) plus the concrete argument + * type (`Box`) and recovers the binding (`T => int`). {@see unify} walks the two trees in + * lock-step, binding each type-parameter leaf to the corresponding concrete sub-type; {@see infer} + * pairs each argument with its parameter, unifies, and assembles the bindings into a + * declaration-ordered type-argument list. + * + * This class is pure and context-free: everything flow-dependent (what type a `$var` or a call + * return actually has) is delegated to an injected {@see ExpressionTyper}. That keeps the load- + * bearing logic — conflict detection, subtype threading, the prefix/gap rules — in one small + * unit-testable place, deliberately outside the monomorphizer's anonymous NodeVisitor classes + * (Infection's blind spot). + * + * Soundness is by construction: inference only ever produces the exact concrete tuple an explicit + * turbofish would have carried, and downstream (bounds, variance edges, mangling, specialization) + * treats the two identically. Anything it cannot resolve to a complete, unambiguous, concrete tuple + * yields null, and the caller leaves the site bare — falling back to today's exact behaviour. + */ +final class TypeInference +{ + public function __construct(private readonly TypeHierarchy $hierarchy) + { + } + + /** + * Infer the type arguments for a generic callee from its call-site arguments. + * + * Returns the inferred type arguments in declaration order — a *complete* tuple, or a concrete + * prefix whose omitted tail is entirely defaulted (so {@see Registry::padArgsWithDefaults} + * fills it exactly as it would for a partial explicit turbofish). Returns null — meaning "leave + * the site bare, fall back to the explicit-turbofish path" — when inference is: + * + * - impossible: a required (non-defaulted) type parameter has no argument witness, or an + * argument's static type is unknown so its parameter stays unconstrained; + * - ambiguous: an argument is a subtype reaching the parameter's type through conflicting + * supertype paths; + * - conflicting: a type parameter used in two parameters is witnessed as two different types + * (`pair(T $a, T $b)` called `pair(1, 'x')`); + * - a "hole": an inferred parameter follows an un-inferred one (not a clean prefix). + * + * @param list $typeParams the callee's generic parameters, in declaration order + * @param array $params the callee's value parameters, from the template AST + * @param array $args the call-site arguments + * @return list|null + */ + public function infer(array $typeParams, array $params, array $args, ExpressionTyper $typer): ?array + { + // A non-generic callee (empty $typeParams) needs no guard here: assemblePrefix() returns + // null for an empty parameter list anyway. + $nameSet = []; + foreach ($typeParams as $param) { + // @infection-ignore-all TrueValue -- $nameSet is a set: membership is tested with + // isset() in paramTypeRef(), so the stored value is immaterial (same rationale as the + // on-path sentinel in TypeHierarchy::groundPaths). + $nameSet[$param->name] = true; + } + + /** @var array $bindings type-param name => inferred concrete type */ + $bindings = []; + foreach (self::pairArgsToParams($params, $args) as [$param, $value]) { + $paramType = self::paramTypeRef($param->type, $nameSet); + if ($paramType === null || !self::mentionsTypeParam($paramType)) { + // The parameter's declared type constrains no type parameter — it contributes + // nothing to inference (and, crucially, leaves all-defaults templates untouched). + continue; + } + $argType = $typer->typeOf($value); + if ($argType === null) { + // Unknown argument type: leave this parameter's type variables unconstrained. + continue; + } + if (!$this->unify($paramType, $argType, $bindings)) { + return null; + } + } + + return self::assemblePrefix($typeParams, $bindings); + } + + /** + * Bind the type-parameter leaves of `$paramType` from the concrete `$argType`, the inverse of + * {@see Specializer::substituteTypeRef}. Accumulates bindings by reference and returns whether + * the two types are unifiable. Failure modes: + * + * - a type-parameter leaf against a non-concrete argument (nothing concrete to bind); + * - a type parameter already bound to a different type (multi-occurrence conflict); + * - a parametric head the argument neither shares nor reaches as a supertype, or reaches + * ambiguously, or with mismatched arity. + * + * A plain (non-generic, non-type-param) parameter leaf imposes no constraint and unifies + * vacuously — inference derives type arguments; it does not re-check argument assignability + * (that is the compiler's separate job, run identically for inferred and explicit turbofishes). + * + * @param array $bindings + * @param-out array $bindings + */ + public function unify(TypeRef $paramType, TypeRef $argType, array &$bindings): bool + { + if ($paramType->isTypeParam) { + if (!$argType->isConcrete()) { + return false; + } + $existing = $bindings[$paramType->name] ?? null; + if ($existing !== null) { + return $existing->canonical() === $argType->canonical(); + } + $bindings[$paramType->name] = $argType; + return true; + } elseif (!$paramType->isGeneric()) { + // A plain concrete leaf imposes no constraint. Kept as an `elseif` deliberately: were + // it a separate `if`, dropping the type-param branch's `return true` above would fall + // through to this same `true` (an equivalent mutant); the chain routes that fall-through + // into the parametric block below instead, where a type-param head is rejected. + return true; + } + // A parametric head: view the argument as this head (directly, or threaded up its supertype + // chain when the argument is a subtype) and unify the type arguments pairwise. + $argArgs = $this->hierarchy->resolveInheritedArgs($argType->name, $argType->args, $paramType->name); + if ($argArgs === null || count($argArgs) !== count($paramType->args)) { + return false; + } + foreach ($paramType->args as $i => $sub) { + if (!$this->unify($sub, $argArgs[$i], $bindings)) { + return false; + } + } + return true; + } + + /** + * Convert a parameter's declared type AST node into a {@see TypeRef}, marking every leaf whose + * name is one of the callee's type parameters (`$typeParamNames`) as a type-param leaf. A + * generic parameter type (`Box`) reuses the type arguments the parser already resolved and + * attached (with their own leaves flagged), so nesting to any depth is handled by the parser's + * own resolution. Returns null for a shape inference does not model — a union, an intersection, + * or a missing type — so its parameter contributes no constraint. + * + * Public so the `new`-inference pass can reuse the exact same parameter→TypeRef mapping over a + * constructor's parameters. + * + * @param array $typeParamNames + */ + public static function paramTypeRef(?Node $type, array $typeParamNames): ?TypeRef + { + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Identifier) { + // A scalar or keyword type (`int`, `string`, `array`, ...) — concrete, no type params. + return new TypeRef(strtolower($type->name), isScalar: true); + } + if ($type instanceof Name) { + $name = $type->toString(); + if (isset($typeParamNames[$name])) { + return new TypeRef($name, isTypeParam: true); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $resolved = $type->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + return new TypeRef(is_string($resolved) ? $resolved : $name, $argRefs); + } + return null; + } + + /** + * Assemble the bindings into a declaration-ordered type-argument list, or null when they do not + * form a "concrete prefix + defaulted tail": every bound parameter must precede every unbound + * one (no hole), and every unbound parameter must be defaultable. An empty result (nothing + * inferred) is null too — the site stays bare. + * + * @param list $typeParams + * @param array $bindings + * @return list|null + */ + private static function assemblePrefix(array $typeParams, array $bindings): ?array + { + $prefix = []; + $sawUnbound = false; + foreach ($typeParams as $param) { + $bound = $bindings[$param->name] ?? null; + if ($bound !== null) { + if ($sawUnbound) { + return null; + } + $prefix[] = $bound; + continue; + } + if ($param->default === null) { + return null; + } + $sawUnbound = true; + } + return $prefix === [] ? null : $prefix; + } + + private static function mentionsTypeParam(TypeRef $ref): bool + { + if ($ref->isTypeParam) { + return true; + } + foreach ($ref->args as $arg) { + if (self::mentionsTypeParam($arg)) { + return true; + } + } + return false; + } + + /** + * Pair each call argument with the callee parameter it binds, mirroring PHP's own rules: + * a named argument binds by parameter name; a positional argument binds by position (a trailing + * variadic absorbs the overflow); a spread (`...$xs`) stops positional pairing, since it + * rebinds every following slot at runtime; and a first-class-callable placeholder carries no + * value and is skipped. Arguments with no matching parameter are dropped. + * + * @param array $params + * @param array $args + * @return list + */ + private static function pairArgsToParams(array $params, array $args): array + { + $pairs = []; + $position = 0; + $sawSpread = false; + foreach ($args as $arg) { + if (!$arg instanceof Arg) { + continue; + } + if ($arg->name instanceof Identifier) { + $param = self::paramByName($params, $arg->name->toString()); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + continue; + } + if ($sawSpread) { + continue; + } + if ($arg->unpack) { + $sawSpread = true; + continue; + } + $param = self::paramForPosition($params, $position); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + $position++; + } + return $pairs; + } + + /** + * The parameter a named argument binds, or null when no parameter has that name. + * + * @param array $params + */ + private static function paramByName(array $params, string $name): ?Param + { + foreach ($params as $param) { + if ($param->var instanceof Variable && $param->var->name === $name) { + return $param; + } + } + return null; + } + + /** + * The parameter a positional argument at `$index` binds: the parameter at that index, or a + * trailing variadic that absorbs everything past the fixed arity, or null when the call + * over-supplies a non-variadic list. + * + * @param array $params + */ + private static function paramForPosition(array $params, int $index): ?Param + { + if (isset($params[$index])) { + return $params[$index]; + } + $last = $params === [] ? null : $params[array_key_last($params)]; + return $last !== null && $last->variadic ? $last : null; + } +} diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 8a4f2683..ad310ac8 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1714,24 +1714,25 @@ function probe(Pair $p): bool { return $p->contains::(new public function testBareInstanceMethodGenericCallFailsCompile(): void { - // A turbofish-less call to a method generic with no all-default params can't infer its type - // argument — it must fail compile, not silently emit a call to the stripped `pick_T_<…>`. + // A turbofish-less call whose arguments don't determine the type parameter — here `T` appears + // only in the return type — cannot be inferred, so it must fail compile, not silently emit a + // call to the stripped `pick_T_<…>`. $this->expectException(RuntimeException::class); $this->expectExceptionMessageMatches('/pick/'); $this->compile([ - 'Box.xphp' => "(T \$x): T { return \$x; } }\n", - 'Use.xphp' => "pick('b');\n", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\n", ]); } public function testBareInstanceMethodGenericCallIsCollectedInCheck(): void { - // The same bare call in `check` mode is collected (not thrown), so a whole-program check reports - // it instead of a runtime fatal — the gap the ticket is about. + // The same non-inferable bare call in `check` mode is collected (not thrown), so a whole-program + // check reports it instead of a runtime fatal — the gap the ticket is about. $collector = $this->check([ - 'Box.xphp' => "(T \$x): T { return \$x; } }\n", - 'Use.xphp' => "pick('b');\n", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\n", ]); $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1760,10 +1761,11 @@ public function testBareCallToANonGenericMethodIsUnaffected(): void public function testBareStaticMethodGenericCallIsReported(): void { - // The static path (`Box::pick('b')`) has the identical silent-skip branch — also reported. + // The static path (`Box::pick(1)`) has the identical silent-skip branch — a non-inferable + // bare call (T only in the return type) is also reported. $collector = $this->check([ - 'Box.xphp' => "(T \$x): T { return \$x; } }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1771,20 +1773,21 @@ public function testBareStaticMethodGenericCallIsReported(): void public function testBareFreeFunctionGenericCallFailsCompile(): void { - // The free-function path skipped bare calls via a different early return; a bare call to a - // generic function must also fail rather than emit a call to the stripped `pick_T_<…>`. + // The free-function path skipped bare calls via a different early return; a non-inferable + // bare call (T only in the return type) must also fail rather than emit a call to the + // stripped `pick_T_<…>`. $this->expectException(RuntimeException::class); $this->compile([ - 'fns.xphp' => "(T \$x): T { return \$x; }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => "check([ - 'fns.xphp' => "(T \$x): T { return \$x; }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php new file mode 100644 index 00000000..c4a91f95 --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -0,0 +1,621 @@ +` turbofish would be. Covers free-function, static-method, and instance-method + * calls; that inference records the same instantiation an explicit turbofish records; that check and + * compile agree; and that a call whose arguments do NOT determine the type still falls back to the + * `xphp.missing_type_argument` error rather than silently emitting a broken call. + */ +final class GenericInferenceIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-inference-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + private const LIB = <<<'PHP' + (T $x): T { return $x; } + final class Factory { public static function make(T $x): T { return $x; } } + final class Bag { public function put(U $x): U { return $x; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredCallArgumentsRunAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. Every call site is bare; that the + // program runs and returns the right values proves the turbofish-less calls inferred their + // type arguments and dispatched to real specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_call_arguments/source', + 'inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_call_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testFreeFunctionInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "`), not left as a bare `identity`. + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testStaticMethodInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "put(9);\n", + ]); + self::assertStringContainsString('put_', self::read($dist, 'Use.php')); + } + + public function testInferenceProducesTheSameSpecializationAsATurbofish(): void + { + // The inferred call and the explicit-turbofish call must dispatch to the byte-identical + // mangled specialization — inference just writes the turbofish for you. + $inferred = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]), 'Use.php'); + + self::assertSame(1, preg_match('/identity_T_\w+/', $inferred, $inferredMatch)); + self::assertSame(1, preg_match('/identity_T_\w+/', $explicit, $explicitMatch)); + self::assertSame($explicitMatch[0], $inferredMatch[0]); + } + + public function testCheckAcceptsAnInferableCall(): void + { + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'an inferable bare call must not be flagged'); + } + + public function testExplicitTurbofishStillCompiles(): void + { + // No regression: an explicit turbofish is unchanged by inference. + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]); + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testFirstClassCallableIsNotInferred(): void + { + // `identity(...)` creates a Closure; it must not be inferred or flagged. + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'a first-class-callable must not be inferred or flagged'); + } + + public function testConflictingArgumentsFallBackToErrorInCheck(): void + { + // pair(T $a, T $b) called (int, string): T is witnessed as two types → no inference → + // today's missing-type-argument error. + $collector = $this->check([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testConflictingArgumentsFailCompile(): void + { + // Parity: the same conflict throws in compile mode. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => "`, a call to the + // non-existent class `U`. It falls back to the missing-type-argument error instead. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingFunctionTypeParamFailsCompile(): void + { + // Parity: compile throws the same error rather than emitting the broken specialization. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + } + + public function testCallArgTypedByMethodTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing METHOD's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Foo { public function m(W \$x): W { \$r = identity(\$x); return \$x; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingClassTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing CLASS's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class C { public function f(E \$e): void { \$r = identity(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testInstanceCallArgTypedByClassTypeParamFallsBack(): void + { + // The instance-method seam: `$this->prop`-less bare instance-generic call whose argument is + // typed by the class type parameter must not infer either. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; } }\nfinal class C { public function f(E \$e): void { \$s = new Sink(); \$r = \$s->take(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testExplicitTurbofishGroundedByEnclosingParamStillWorks(): void + { + // The correct alternative — an explicit turbofish grounded by the enclosing type parameter — + // still grounds per specialization (unchanged by inference): `outer::` emits a call to + // a concrete `identity_T_`, never a reference to the abstract `U`. + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity::(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $lib = self::read($dist, 'Lib.php'); + self::assertStringContainsString('identity_T_', $lib); + self::assertStringNotContainsString('\\App\\U', $lib, 'no reference to the abstract type parameter U'); + } + + // --- `new` inference ----------------------------------------------------------------------- + + private const BOX = <<<'PHP' + { public function __construct(private T $value) {} public function get(): T { return $this->value; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredNewArgumentsRunAtRuntime(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_new_arguments/source', + 'new-inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_new_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testBareNewInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => ">` — the SAME specialization the explicit + // turbofish selects — not `Box` read off an un-annotated inner node. Regression for the + // bottom-up (leaveNode) annotation order. + $box = " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\n"; + $inferred = self::read($this->compile([ + 'Box.xphp' => $box, + 'Use.xphp' => "compile([ + 'Box.xphp' => $box, + 'Use.xphp' => ">(new Box::(5));\n", + ]), 'Use.php'); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $explicit, $e)); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $inferred, $i)); + // The inferred outer + inner specializations are exactly the two the turbofish produces. + self::assertSame($e[0], $i[0], 'inferred nested new selects the same specializations as the turbofish'); + self::assertCount(2, array_unique($i[0]), 'outer and inner are distinct specializations'); + } + + public function testCallInfersFromAClassReturningCallArgument(): void + { + // A call argument that is itself a call returning a determinable class is an inference source + // for the call path (it reuses the receiver flow engine): `identity($f->make())` infers + // T=Plastic. (The `new` pass is more conservative and would not.) + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Factory { public function make(): Plastic { return new Plastic(); } }\nfinal class R { public function go(Factory \$f): Plastic { return identity(\$f->make()); } }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'a `new` whose argument determines T must not be flagged'); + } + + public function testNonInferableBareNewStillErrorsInCheck(): void + { + // T appears only in a method return, so `new Box()` cannot infer it → still an error. + $collector = $this->check([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNonInferableBareNewThrowsInCompile(): void + { + // Parity with check. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => "; without the + // pop it would read the closure's scope (which has $wp, not $op) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfunction outer(Plastic \$op): Plastic { \$f = function (Widget \$wp): Widget { return \$wp; }; \$unused = \$f; \$b = new Box(\$op); return \$b->get(); }\n", + 'Use.xphp' => "ap` must resolve against the OUTER class + // A — i.e. the class stack is popped on leave. `new Box($this->ap)` infers Box; + // without the pop it would scan the anon class (which has no `$ap`) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfinal class A { private Plastic \$ap; public function __construct() { \$this->ap = new Plastic(); } public function m(): Plastic { \$inner = new class { private ?Widget \$wp = null; public function n(): ?Widget { return \$this->wp; } }; \$b = new Box(\$this->ap); return \$b->get(); } }\n", + 'Use.xphp' => " from the stale declaration (the value is now an int). + // It falls back to requiring an explicit turbofish instead of emitting an unsound type. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testAllDefaultsBareNewStillSynthesizes(): void + { + // No regression: a bare `new` of an all-defaults template still pads from defaults; the + // inference pass leaves it bare (nothing to infer) and the synthesis path handles it. + $dist = $this->compile([ + 'Cache.xphp' => " { public function size(): int { return 0; } }\n", + 'Use.xphp' => "compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all()); + } + + public function testVariableVariableArgumentFallsBack(): void + { + // `new Box($$name)` — the argument is a variable-variable (its name is an expression, not a + // string), so it can't be typed; inference falls back rather than mishandling the name. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNewInfersFromPropertyDeclaredAfterAMethod(): void + { + // The property scan must skip non-property statements (a method here) and keep looking, + // not stop at the first one. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { public function boot(): void {} private Plastic \$p; public function f(): void { \$b = new Box(\$this->p); } }\n", + 'Use.xphp' => "all(), 'new Box($this->p) must infer even when p follows a method'); + } + + public function testNewFromUnionTypedPropertyFallsBack(): void + { + // A union-typed property is a shape paramTypeRef does not model (null), so the `new` falls + // back rather than dereferencing a null type. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { private int|string \$u = 1; public function f(): void { \$b = new Box(\$this->u); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testMethodGenericParameterIsNotTrustedForNewInference(): void + { + // Inside a generic method, a parameter typed by the METHOD type parameter is abstract, so + // `new Box($x)` must not infer from it (that would fabricate a bogus class argument). + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class M { public function make(U \$x): void { \$b = new Box(\$x); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testArrowFunctionPresentDoesNotBreakInference(): void + { + // An arrow function has no statement body (getStmts() is null); the reassignment scan must + // tolerate that, and a bare `new Box(5)` alongside it still infers. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " \$x;\n\$b = new Box(5);\n", + ]); + self::assertStringContainsString('Generated\\App\\Box\\T_', self::read($dist, 'Use.php')); + } + + public function testEveryReassignmentFormDropsTheParameter(): void + { + // Each reassignment form — `=`, `+=`, `=&`, `++x`, `x++`, `--x`, `x--` — must mark the + // parameter untrusted, so every `new Box($x)` falls back: one missing-type-argument per + // site. Pins the whole reassignment-detection predicate (and that ALL names are dropped, + // not just the first). + $body = '$a = 5; $b += 1; $ref = 1; $c =& $ref; ++$d; $e++; --$f; $g--; ' + . 'new Box($a); new Box($b); new Box($c); new Box($d); new Box($e); new Box($f); new Box($g);'; + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame(array_fill(0, 7, Registry::CODE_MISSING_TYPE_ARGUMENT), $codes); + } + + public function testReassignedParameterDoesNotBlockLaterTrustedParameter(): void + { + // The first parameter is reassigned (skipped), but the scan must CONTINUE to the second, + // trustworthy parameter — not stop at the first. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class C { public function f(E \$e): void { \$b = new Box(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBodilessMethodDoesNotBreakTheReassignmentScan(): void + { + // An interface method has no body (getStmts() is null); the reassignment scan must tolerate + // that. A bare `new Box(5)` in the same program still infers. + $dist = $this->compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'both new Box(...) infer from their trusted parameters'); + } + + public function testMultipleClassTypeParametersAreAllRecognized(): void + { + // A class with two type parameters: BOTH must be recognized as abstract (the name set isn't + // truncated), so neither `new Box($a)` nor `new Box($b)` infers → two fallbacks. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class P { public function f(A \$a, B \$b): void { \$x = new Box(\$a); \$y = new Box(\$b); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame( + [Registry::CODE_MISSING_TYPE_ARGUMENT, Registry::CODE_MISSING_TYPE_ARGUMENT], + $codes, + ); + } + + public function testConstructorNameMatchedCaseInsensitively(): void + { + // PHP method names are case-insensitive; a `__Construct` constructor must still be found. + $collector = $this->check([ + 'Box.xphp' => " { public function __Construct(private T \$v) {} }\n", + 'Use.xphp' => "all(), 'a __Construct constructor is found case-insensitively, so T infers'); + } + + // --- helpers (kept local, matching the other Monomorphize integration tests) --------------- + + /** @param array $files */ + private function compile(array $files): string + { + $src = $this->writeSources($files); + $dist = $src . '/dist'; + $this->newCompiler()->compile($this->sourcesIn($src), $src, $dist, $src . '/.xphp-cache'); + return $dist; + } + + /** @param array $files */ + private function check(array $files): DiagnosticCollector + { + $src = $this->writeSources($files); + return $this->newCompiler()->check($this->sourcesIn($src)); + } + + /** @param array $files */ + private function writeSources(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + return $src; + } + + private function sourcesIn(string $src): \XPHP\FileSystem\FilepathArray + { + return (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function newCompiler(): Compiler + { + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/LiteralTyperTest.php b/test/Transpiler/Monomorphize/LiteralTyperTest.php new file mode 100644 index 00000000..169cee4c --- /dev/null +++ b/test/Transpiler/Monomorphize/LiteralTyperTest.php @@ -0,0 +1,128 @@ +typer = new LiteralTyper(); + } + + public function testIntLiteral(): void + { + $ref = $this->typer->typeOf(new Int_(5)); + self::assertNotNull($ref); + self::assertSame('int', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testFloatLiteral(): void + { + $ref = $this->typer->typeOf(new Float_(1.5)); + self::assertNotNull($ref); + self::assertSame('float', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testStringLiteral(): void + { + $ref = $this->typer->typeOf(new String_('x')); + self::assertNotNull($ref); + self::assertSame('string', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testTrueAndFalseAreBool(): void + { + foreach (['true', 'false', 'TRUE', 'False'] as $literal) { + $ref = $this->typer->typeOf(new ConstFetch(new Name($literal))); + self::assertNotNull($ref, $literal); + self::assertSame('bool', $ref->name); + self::assertTrue($ref->isScalar); + } + } + + public function testNullConstantIsNotTyped(): void + { + self::assertNull($this->typer->typeOf(new ConstFetch(new Name('null')))); + } + + public function testOtherConstantIsNotTyped(): void + { + self::assertNull($this->typer->typeOf(new ConstFetch(new Name('PHP_EOL')))); + } + + public function testArrayLiteral(): void + { + $ref = $this->typer->typeOf(new Array_([])); + self::assertNotNull($ref); + self::assertSame('array', $ref->name); + // isScalar mirrors the parser, which types an `array` turbofish arg as scalar — inference + // must produce the byte-identical TypeRef an explicit `::` would. + self::assertTrue($ref->isScalar); + } + + public function testNewNonGeneric(): void + { + $class = new Name('Plastic'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Plastic'); + $ref = $this->typer->typeOf(new New_($class)); + self::assertNotNull($ref); + self::assertSame('App\\Plastic', $ref->name); + self::assertSame([], $ref->args); + } + + public function testNewWithTurbofishArgs(): void + { + // new Box::() — the parser has attached the resolved turbofish args. + $class = new Name('Box'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Box'); + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('int', isScalar: true)]); + $ref = $this->typer->typeOf(new New_($class)); + self::assertNotNull($ref); + self::assertSame('App\\Box', $ref->canonical()); + } + + public function testNewWithoutResolvedFqnFallsBackToSpelling(): void + { + $ref = $this->typer->typeOf(new New_(new Name('Whatever'))); + self::assertNotNull($ref); + self::assertSame('Whatever', $ref->name); + } + + public function testNewGenericWithAbstractArgIsNotTyped(): void + { + // new Box::() inside a template: the turbofish is not concrete, so it is no basis for + // inference. + $class = new Name('Box'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Box'); + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); + self::assertNull($this->typer->typeOf(new New_($class))); + } + + public function testNewDynamicClassIsNotTyped(): void + { + // new $class() — the class is an expression, not a name. + self::assertNull($this->typer->typeOf(new New_(new Variable('class')))); + } + + public function testUntypedExpressionYieldsNull(): void + { + self::assertNull($this->typer->typeOf(new Variable('x'))); + } +} diff --git a/test/Transpiler/Monomorphize/TypeInferenceTest.php b/test/Transpiler/Monomorphize/TypeInferenceTest.php new file mode 100644 index 00000000..f55117cf --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeInferenceTest.php @@ -0,0 +1,639 @@ +(T $x) called with an int → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer(['a' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testMultiOccurrenceConsistentBindingSucceeds(): void + { + // pair(T $a, T $b) with (int, int) → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testMultiOccurrenceConflictFallsBack(): void + { + // pair(T $a, T $b) with (int, string) → conflict → null + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::string()]), + ); + self::assertNull($result); + } + + public function testInfersThroughMatchingParametricHead(): void + { + // unwrap(Box $b) with Box → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('b', self::generic(self::BOX, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => self::box(self::int())]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testInfersThroughNestedParametricHeads(): void + { + // f(Box> $b) with Box> → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('b', self::generic(self::BOX, [ + new TypeRef(self::BOX, [self::typeParamRef('T')]), + ]))], + [self::arg('x')], + self::typer(['x' => self::box(self::box(self::int()))]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testInfersThroughSubtypeSupertypeChain(): void + { + // take(Collection $c) with an ArrayList argument → [int] + $result = $this->infer($this->arrayListImplementsCollection())->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef(self::ARRAY_LIST, [self::int()])]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testAmbiguousSupertypePathFallsBack(): void + { + // A type that grounds Collection to two different args along two paths → null. + $hierarchy = new TypeHierarchy( + ['App\\Weird' => [self::COLLECTION]], + ['App\\Weird' => [ + new TypeRef(self::COLLECTION, [self::int()]), + new TypeRef(self::COLLECTION, [self::string()]), + ]], + ['App\\Weird' => [], self::COLLECTION => ['E']], + ); + $result = $this->infer($hierarchy)->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Weird', [])]), + ); + self::assertNull($result); + } + + public function testUnrelatedParametricHeadFallsBack(): void + { + // Collection parameter, an argument whose type reaches no Collection supertype → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Unrelated', [self::int()])]), + ); + self::assertNull($result); + } + + public function testArityMismatchOnParametricHeadFallsBack(): void + { + // Pair parameter head, a two-arg argument against a one-arg parameter shape → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('p', self::generic('App\\Pair', [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Pair', [self::int(), self::string()])]), + ); + self::assertNull($result); + } + + public function testDefaultedTrailingParamIsOmittedFromInferredPrefix(): void + { + // pair(A $a) with an int → [int]; B is left for padding to default. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B', null, self::typeParamRef('A'))], + [self::param('a', new Name('A'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testHoleBetweenBoundParamsFallsBack(): void + { + // two(B $b): only the second type param is witnessed → not a clean prefix → null. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('b', new Name('B'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testUnwitnessedRequiredParamFallsBack(): void + { + // two(A $a): B is required but has no argument witness → null. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('a', new Name('A'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testUnknownArgumentTypeLeavesParamUnconstrained(): void + { + // identity(T $x) with an argument the typer cannot type → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer([]), + ); + self::assertNull($result); + } + + public function testAbstractArgumentAgainstTypeParamFallsBack(): void + { + // A non-concrete argument type cannot bind a type parameter → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer(['a' => self::typeParamRef('U')]), + ); + self::assertNull($result); + } + + public function testUnionTypedParamIsSkipped(): void + { + // f(int|string $u, T $x): the union parameter is a shape inference does not model, so it + // is skipped (short-circuit) and T is still inferred from the second argument → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [ + self::param('u', new UnionType([new Identifier('int'), new Identifier('string')])), + self::param('x', new Name('T')), + ], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testEarlierUnknownArgumentDoesNotBlockLaterBinding(): void + { + // pair(T $a, T $b) with (unknown, int): the first argument's type is unknown, but the + // second still witnesses T → [int]. (Distinguishes "skip this pair" from "stop pairing".) + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testConcreteGenericParamImposesNoConstraint(): void + { + // f(Box $fixed, T $x) with (an unrelated-typed arg, int): the concrete Box + // parameter mentions no type parameter, so it is skipped rather than unified against the + // unrelated argument (which would spuriously fail) → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [ + self::param('fixed', self::generic(self::BOX, [self::int()])), + self::param('x', new Name('T')), + ], + [self::arg('a'), self::arg('b')], + self::typer(['a' => new TypeRef('App\\Unrelated', [self::int()]), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testHoleAfterDefaultedParamFallsBack(): void + { + // pair(B $b): only the second (defaulted) param is witnessed while the + // first is not — a hole that would misalign the prefix, so inference falls back → null. + $result = $this->infer()->infer( + [new TypeParam('A', null, self::int()), new TypeParam('B', null, self::int())], + [self::param('b', new Name('B'))], + [self::arg('x')], + self::typer(['x' => self::string()]), + ); + self::assertNull($result); + } + + public function testPlainConcreteParamImposesNoConstraint(): void + { + // f(int $n, T $x) with (int, string) → [string]; the int parameter constrains nothing. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('n', new Identifier('int')), self::param('x', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::string()]), + ); + self::assertSame('string', self::canonicals($result)); + } + + public function testNonGenericCalleeInfersNothing(): void + { + self::assertNull($this->infer()->infer([], [], [], self::typer([]))); + } + + public function testAllDefaultsTemplateWithNoTypeParamParamsStaysBare(): void + { + // cache() — no parameter mentions T, so nothing is inferred; the site stays bare + // and the existing all-defaults path is untouched. + $result = $this->infer()->infer( + [new TypeParam('T', null, self::int())], + [], + [], + self::typer([]), + ); + self::assertNull($result); + } + + // ---- argument pairing --------------------------------------------------------------------- + + public function testNamedArgumentsBindByParameterName(): void + { + // kv(K $k, V $v) called v: string, k: int (out of order) → [int, string] + $result = $this->infer()->infer( + [new TypeParam('K'), new TypeParam('V')], + [self::param('k', new Name('K')), self::param('v', new Name('V'))], + [self::namedArg('v', 'sv'), self::namedArg('k', 'sk')], + self::typer(['sv' => self::string(), 'sk' => self::int()]), + ); + self::assertSame('int,string', self::canonicals($result)); + } + + public function testUnknownNamedArgumentIsDropped(): void + { + // A named argument matching no parameter is ignored; T stays unwitnessed → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::namedArg('nope', 'a')], + self::typer(['a' => self::int()]), + ); + self::assertNull($result); + } + + public function testSpreadStopsPositionalPairing(): void + { + // f(A $a, B $b): a spread in first position leaves both params unwitnessed → null. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('a', new Name('A')), self::param('b', new Name('B'))], + [$spread, self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testVariadicParamAbsorbsOverflowConsistently(): void + { + // all(T ...$xs) with (int, int) → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::variadicParam('xs', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testVariadicParamConflictFallsBack(): void + { + // all(T ...$xs) with (int, string) → conflict via the shared variadic slot → null + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::variadicParam('xs', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::string()]), + ); + self::assertNull($result); + } + + public function testExcessPositionalArgWithoutVariadicIsDropped(): void + { + // one(T $a) called with a second positional arg: the overflow has no parameter and is + // dropped; inference still succeeds from the first argument → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::string()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testFirstClassCallablePlaceholderIsSkipped(): void + { + // identity(...) — the placeholder carries no value, so nothing is inferred → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [new Node\VariadicPlaceholder()], + self::typer([]), + ); + self::assertNull($result); + } + + // ---- unify(): direct micro-coverage ------------------------------------------------------- + + public function testUnifyBindsTypeParamLeaf(): void + { + $bindings = []; + self::assertTrue($this->infer()->unify(self::typeParamRef('T'), self::int(), $bindings)); + self::assertSame('int', $bindings['T']->canonical()); + } + + public function testUnifyRejectsNonConcreteArgument(): void + { + $bindings = []; + self::assertFalse( + $this->infer()->unify(self::typeParamRef('T'), self::typeParamRef('U'), $bindings), + ); + self::assertSame([], $bindings); + } + + public function testUnifyRejectsConflictingRebinding(): void + { + $bindings = ['T' => self::int()]; + self::assertFalse($this->infer()->unify(self::typeParamRef('T'), self::string(), $bindings)); + } + + public function testUnifyPlainConcreteLeafSucceedsWithoutBinding(): void + { + $bindings = []; + self::assertTrue($this->infer()->unify(self::int(), self::string(), $bindings)); + self::assertSame([], $bindings); + } + + public function testUnifyRejectsUnrelatedParametricHead(): void + { + // Box against Collection: the argument's head neither matches nor reaches Box. + $bindings = []; + self::assertFalse($this->infer()->unify( + new TypeRef(self::BOX, [self::typeParamRef('T')]), + new TypeRef(self::COLLECTION, [self::int()]), + $bindings, + )); + } + + public function testUnifyRejectsParametricHeadArityMismatch(): void + { + // Box (one arg) against Box (two): a direct-hit head with mismatched arity. + $bindings = []; + self::assertFalse($this->infer()->unify( + new TypeRef(self::BOX, [self::typeParamRef('T')]), + new TypeRef(self::BOX, [self::int(), self::string()]), + $bindings, + )); + } + + // ---- argument pairing: spread and placeholder branches ----------------------------------- + + public function testSpreadDisablesPositionalPairing(): void + { + // f(T $a) with (...$rest, positional int): the spread makes the following positional + // unsound to pair, so T is never witnessed → null. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [$spread, self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testNamedArgumentsAfterSpreadStillBind(): void + { + // f(T $a, T $b) with (...$rest, a: int, b: int): a spread stops positional pairing but + // named arguments after it still bind by name → [int]. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [$spread, self::namedArg('a', 'x'), self::namedArg('b', 'y')], + self::typer(['x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testPositionalAfterSpreadSkippedButLaterNamedBinds(): void + { + // f(T $a, T $b) with (...$rest, positional junk, a: int, b: int): the post-spread + // positional is skipped (not a hard stop), and the later named arguments still bind → [int]. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [$spread, self::arg('junk'), self::namedArg('a', 'x'), self::namedArg('b', 'y')], + self::typer(['junk' => self::string(), 'x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testPlaceholderBeforeArgumentDoesNotStopPairing(): void + { + // A first-class-callable placeholder is skipped (not a hard stop): a following ordinary + // argument still pairs. (Defensive — valid PHP never mixes the two, but the pairing must + // not break if it sees this shape.) + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [new Node\VariadicPlaceholder(), self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + // ---- paramTypeRef() ----------------------------------------------------------------------- + + public function testParamTypeRefMarksTypeParamLeaf(): void + { + $ref = TypeInference::paramTypeRef(new Name('T'), ['T' => true]); + self::assertNotNull($ref); + self::assertTrue($ref->isTypeParam); + self::assertSame('T', $ref->name); + } + + public function testParamTypeRefUnwrapsNullable(): void + { + $ref = TypeInference::paramTypeRef(new NullableType(new Name('T')), ['T' => true]); + self::assertNotNull($ref); + self::assertTrue($ref->isTypeParam); + } + + public function testParamTypeRefTreatsScalarAsConcrete(): void + { + $ref = TypeInference::paramTypeRef(new Identifier('INT'), ['T' => true]); + self::assertNotNull($ref); + self::assertFalse($ref->isTypeParam); + self::assertTrue($ref->isScalar); + self::assertSame('int', $ref->name); + } + + public function testParamTypeRefReadsGenericArgsAndResolvedFqn(): void + { + $ref = TypeInference::paramTypeRef(self::generic(self::BOX, [self::typeParamRef('T')]), ['T' => true]); + self::assertNotNull($ref); + self::assertFalse($ref->isTypeParam); + self::assertSame(self::BOX, $ref->name); + self::assertSame('App\\Box', $ref->canonical()); + } + + public function testParamTypeRefOnConcreteNameWithoutResolvedFqnFallsBackToSpelling(): void + { + $ref = TypeInference::paramTypeRef(new Name('Whatever'), ['T' => true]); + self::assertNotNull($ref); + self::assertSame('Whatever', $ref->name); + self::assertSame([], $ref->args); + } + + public function testParamTypeRefReturnsNullForUnmodeledShapes(): void + { + self::assertNull(TypeInference::paramTypeRef(null, ['T' => true])); + self::assertNull(TypeInference::paramTypeRef( + new UnionType([new Name('T'), new Identifier('int')]), + ['T' => true], + )); + } + + // ---- helpers ------------------------------------------------------------------------------ + + private function infer(?TypeHierarchy $hierarchy = null): TypeInference + { + return new TypeInference($hierarchy ?? new TypeHierarchy([])); + } + + private function arrayListImplementsCollection(): TypeHierarchy + { + return new TypeHierarchy( + [self::ARRAY_LIST => [self::COLLECTION]], + [self::ARRAY_LIST => [new TypeRef(self::COLLECTION, [self::typeParamRef('E')])]], + [self::ARRAY_LIST => ['E'], self::COLLECTION => ['E']], + ); + } + + /** @param list|null $result */ + private static function canonicals(?array $result): string + { + self::assertNotNull($result); + return implode(',', array_map(static fn (TypeRef $r): string => $r->canonical(), $result)); + } + + private static function param(string $var, Node $type): Param + { + return new Param(new Variable($var), null, $type); + } + + private static function variadicParam(string $var, Node $type): Param + { + return new Param(new Variable($var), null, $type, false, true); + } + + private static function generic(string $shortResolved, array $args): Name + { + $name = new Name('Short'); + $name->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $args); + $name->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, $shortResolved); + return $name; + } + + private static function arg(string $var): Arg + { + return new Arg(new Variable($var)); + } + + private static function namedArg(string $paramName, string $var): Arg + { + return new Arg(new Variable($var), name: new Identifier($paramName)); + } + + /** @param array $map variable name → its static type */ + private static function typer(array $map): ExpressionTyper + { + return new class ($map) implements ExpressionTyper { + /** @param array $map */ + public function __construct(private readonly array $map) + { + } + + public function typeOf(Expr $expr): ?TypeRef + { + if ($expr instanceof Variable && is_string($expr->name)) { + return $this->map[$expr->name] ?? null; + } + return null; + } + }; + } + + private static function int(): TypeRef + { + return new TypeRef('int', isScalar: true); + } + + private static function string(): TypeRef + { + return new TypeRef('string', isScalar: true); + } + + private static function typeParamRef(string $name): TypeRef + { + return new TypeRef($name, isTypeParam: true); + } + + private static function box(TypeRef $inner): TypeRef + { + return new TypeRef(self::BOX, [$inner]); + } +} diff --git a/test/fixture/check/bare_new_missing_arg/source/Box.xphp b/test/fixture/check/bare_new_missing_arg/source/Box.xphp index 5b5942aa..92c767d1 100644 --- a/test/fixture/check/bare_new_missing_arg/source/Box.xphp +++ b/test/fixture/check/bare_new_missing_arg/source/Box.xphp @@ -6,7 +6,9 @@ namespace App\Check\BareNewMissingArg; class Box { - public function __construct(public T $v) + // T appears only in a method return, so a bare `new Box()` cannot infer it. + public function get(): ?T { + return null; } } diff --git a/test/fixture/check/bare_new_missing_arg/source/Use.xphp b/test/fixture/check/bare_new_missing_arg/source/Use.xphp index 220602fe..5b4f351f 100644 --- a/test/fixture/check/bare_new_missing_arg/source/Use.xphp +++ b/test/fixture/check/bare_new_missing_arg/source/Use.xphp @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Check\BareNewMissingArg; -// Bare `new` of a non-defaults generic, no turbofish: cannot pad from defaults. -// Silently emitting would leave the site pointing at the stripped marker -// `interface Box {}` -> a runtime "cannot instantiate interface" fatal. Rejected. -$b = new Box(5); +// Bare `new` whose type argument can't be inferred (T is not in a constructor +// parameter) and can't be defaulted: silently emitting would leave the site +// pointing at the stripped marker `interface Box {}` -> a runtime fatal. Rejected. +$b = new Box(); diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp index d9f5523c..dfe0be12 100644 --- a/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp @@ -6,7 +6,9 @@ namespace App\Check\BareNewMissingArgQualified; class Box { - public function __construct(public T $v) + // T appears only in a method return, so a bare `new Box()` cannot infer it. + public function get(): ?T { + return null; } } diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp index 4f98c427..25080bf9 100644 --- a/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Check\BareNewMissingArgQualified; // Fully-qualified and relative spellings must reject with the SAME verdict as the -// bare spelling -- both resolve to the same non-defaults template. Two diagnostics -// in one run (check collects, does not throw-on-first). -$a = new \App\Check\BareNewMissingArgQualified\Box(5); -$b = new namespace\Box(6); +// bare spelling -- both resolve to the same non-defaults, non-inferable template. +// Two diagnostics in one run (check collects, does not throw-on-first). +$a = new \App\Check\BareNewMissingArgQualified\Box(); +$b = new namespace\Box(); diff --git a/test/fixture/compile/inferred_call_arguments/source/Lib.xphp b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp new file mode 100644 index 00000000..466573a3 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp @@ -0,0 +1,62 @@ +(T $x): T +{ + return $x; +} + +function wrap(T $x): T +{ + return $x; +} + +final class Box +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } + + public function dup(U $x): U + { + return $x; + } +} + +final class Factory +{ + public static function make(T $x): T + { + return $x; + } +} + +final class Consumer +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // Argument typed from a class-typed property (`$this->p`). + public function viaProp(): Plastic + { + return wrap($this->p); + } + + // Argument typed from a class-typed parameter (`$q`). + public function viaParam(Plastic $q): Plastic + { + return wrap($q); + } +} diff --git a/test/fixture/compile/inferred_call_arguments/source/Models.xphp b/test/fixture/compile/inferred_call_arguments/source/Models.xphp new file mode 100644 index 00000000..b8b60b59 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +(7); +$dupped = $box->dup(9); + +// Free-function inference from a class-typed property and a class-typed parameter. +$consumer = new Consumer(); +$viaProp = $consumer->viaProp(); +$viaParam = $consumer->viaParam(new Plastic()); diff --git a/test/fixture/compile/inferred_call_arguments/verify/runtime.php b/test/fixture/compile/inferred_call_arguments/verify/runtime.php new file mode 100644 index 00000000..ff5a3544 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/verify/runtime.php @@ -0,0 +1,35 @@ +targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intId, 'identity(5) inferred T=int and returned the value'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $objId, 'identity(new Plastic()) inferred T=Plastic'); + Assert::assertSame('hi', $made, 'Factory::make(\'hi\') inferred T=string'); + Assert::assertSame(9, $dupped, '$box->dup(9) inferred U=int'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaProp, 'wrap($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaParam, 'wrap($q) inferred T=Plastic from the parameter type'); +}; diff --git a/test/fixture/compile/inferred_new_arguments/source/Lib.xphp b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp new file mode 100644 index 00000000..8a5b1846 --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp @@ -0,0 +1,39 @@ + +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } +} + +final class Holder +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // `new Box($this->p)` — inferred from the declared property type. + public function boxProp(): Plastic + { + return (new Box($this->p))->get(); + } + + // `new Box($q)` — inferred from the declared parameter type. + public function boxParam(Plastic $q): Plastic + { + return (new Box($q))->get(); + } +} diff --git a/test/fixture/compile/inferred_new_arguments/source/Models.xphp b/test/fixture/compile/inferred_new_arguments/source/Models.xphp new file mode 100644 index 00000000..f168269f --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +get(); +$boxObj = new Box(new Plastic()); +$objVal = $boxObj->get(); + +// Bare `new` inference from a class-typed property and a class-typed parameter. +$holder = new Holder(); +$fromProp = $holder->boxProp(); +$fromParam = $holder->boxParam(new Plastic()); diff --git a/test/fixture/compile/inferred_new_arguments/verify/runtime.php b/test/fixture/compile/inferred_new_arguments/verify/runtime.php new file mode 100644 index 00000000..faf886fd --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/verify/runtime.php @@ -0,0 +1,30 @@ +p`), and a + * class-typed parameter. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated Box specializations are + * autoloaded. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intVal, 'new Box(5) inferred T=int'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $objVal, 'new Box(new Plastic()) inferred T=Plastic'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromProp, 'new Box($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromParam, 'new Box($q) inferred T=Plastic from the parameter type'); +};