feat: infer generic type arguments (make the turbofish optional) - #31
feat: infer generic type arguments (make the turbofish optional)#31math3usmartins wants to merge 9 commits into
Conversation
Introduce TypeInference, the pure core that derives a generic call or instantiation's type arguments from its ordinary arguments' static types -- the structural inverse of Specializer::substituteTypeRef. It carries no pipeline state: the unifier walks a parameter type and a concrete argument type in lock-step binding each type-parameter leaf, subtype arguments are threaded up the supertype chain via TypeHierarchy::resolveInheritedArgs, and the driver pairs arguments to parameters (positional, named, variadic, spread- and first-class-callable-aware), unifies, and assembles the bindings into a declaration-ordered type-argument prefix. Inference resolves only to a complete, unambiguous, concrete tuple (or a concrete prefix whose omitted tail is entirely defaulted); a conflict, ambiguity, unknown argument type, or a hole yields null, so a later caller can leave the site bare and fall back to the explicit-turbofish path unchanged. Flow-dependent typing is delegated through the ExpressionTyper seam, keeping this logic outside the monomorphizer's anonymous NodeVisitor classes where it is directly unit- and mutation-testable. No wiring yet -- this class has no callers; the call and new sites adopt it in following commits. 44 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add LiteralTyper, the context-free ExpressionTyper the inference driver uses for arguments whose static type is read directly off the expression: scalar literals (int/float/string/bool), array literals, and object construction (new X(...), carrying any turbofish the parser resolved onto the new). It returns null for anything flow-dependent -- a variable, a property, a call return -- and for a construction that is not fully concrete (an un-turbofished generic new, or an anonymous/dynamic class), never inventing a type it cannot read completely. An array literal is typed isScalar like the parser types an `array` turbofish argument, so an inferred tuple is byte-identical to the one an explicit ::<array> would produce. Still no callers -- the call and new sites wire this in with the flow tracker in following commits. 13 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the turbofish optional on generic function, static-method, and instance-method calls: at each bare-call seam, before the missing-type- argument error, infer the type arguments from the call arguments and, on a complete concrete tuple, dispatch exactly as an explicit ::<> turbofish would. The monomorphizer's rewrite visitor now implements ExpressionTyper, answering argument types from LiteralTyper (literals, new) and its own receiver/scope tracking (class-typed parameters and locals, $this properties, class-returning calls); TypeInference turns those into the type-argument tuple. A miss leaves the site bare, so a call whose arguments don't determine the type parameter (e.g. one used only in the return type), a type conflict, or an unknown argument type still hits today's xphp.missing_type_argument, identically in check and compile. Because an inferred call is annotated to be indistinguishable from a turbofished one, everything downstream -- bounds, variance edges, mangling, specialization, check/compile parity -- is unchanged and cannot tell the two apart. First-class-callables are never inferred, and generic closure ($var) calls keep the explicit-turbofish requirement (deferred). The five enclosing-param bare-call tests that asserted the old "bare call always errors" behavior now use genuinely non-inferable shapes (the type parameter only in the return type), preserving what they guard -- that an unresolvable bare call still errors loudly rather than emitting a broken call. A new integration test compiles, executes, and check-verifies inference across all three call kinds and every supported argument shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the turbofish optional on generic class instantiation. A new front-end pass (NewInferencePass) infers a bare `new Box($x)`'s type arguments from its constructor arguments and annotates the node exactly as an explicit turbofish (or the all-defaults synthesis) would -- so the instantiation collector and, in compile, the call-site rewriter treat it identically. A `new` it can't resolve to a complete concrete tuple is left bare, falling back to today's all-defaults synthesis or missing-type- argument error. The pass runs after collectDefinitions (it needs the template registry) and before collectInstantiations, at the same position in both check and compile, so the two modes infer identically. In compile, collectDefinitions and the pass now run before the method compiler, so the pass sees the original ASTs -- not the stripped/appended ones the method compiler produces (which check never sees). The method compiler still runs before collectInstantiations, so its appended specializations are collected as before. Argument typing is conservative and sound: literals and `new` (via LiteralTyper), `$this` properties (from the declared type), and plain parameters -- but only a concretely-typed parameter that is never reassigned, so a rebound `$x` can't be typed from its stale declaration. A local, a reassigned parameter, a union type, or a type-parameter-typed argument yields no inference and the `new` falls back. The two bare-new check fixtures now use a genuinely non-inferable template (the type parameter only in a method return), preserving what they guard. Integration tests compile, execute, and check-verify `new` inference across literal/`new`/property/parameter arguments; NewInferencePass is at 100% MSI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that a bare generic call attempts inference, the free-function/closure diagnostic no longer claims a generic "takes no inference". It states the type arguments could not be inferred from the call arguments and to supply an explicit turbofish. The error code (xphp.missing_type_argument) and the collect-vs-throw behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the comparison grid's type-argument-inference row from ❌ to⚠️ (inferred for calls and `new` where the arguments determine the type; explicit turbofish still required otherwise) and rewrite the accompanying prose — xphp now infers by default like Rust, reaching for the turbofish to disambiguate or where inference can't see the type. Drop the stale "no inference" note from the generic-functions/methods row. Add a "Type-argument inference is partial" caveat (what is and isn't inferred, why the argument-typing is conservative, and the turbofish workaround), a turbofish-syntax section describing inference and its sources, rewrite the mandatory-turbofish rule as optional-where-inferable, update the xphp.missing_type_argument error entry, and add a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare generic call whose argument was typed by an in-scope type parameter (the enclosing function/method/class parameter, e.g. `identity($x)` inside `outer<U>(U $x)`) inferred that parameter as a concrete class: the flow tracker stores a parameter's declared type as a resolved FQN (`App\U`), which typeOf() wrapped in a concrete TypeRef with no isTypeParam flag. The call then dispatched `identity::<App\U>` and emitted a specialization referencing the non-existent class `App\U` — `check` reported clean, `compile` succeeded, and the program fataled at runtime. typeOf() (and typeOfThisProperty) now treat such a value as abstract and return null, so the site falls back to the exact missing-type-argument error it produced before inference existed. In-scope type-parameter names are tracked through the scope stack (enclosing functions/methods/closures) and read from the enclosing class, mirroring NewInferencePass. The explicit turbofish grounded by the enclosing parameter (`identity::<U>($x)`) is unaffected and still grounds per specialization. Regression tests cover the argument-typed-by-a-function/method/class-type- parameter cases for the free-function, static, and instance seams, in both check and compile, and pin that the explicit-turbofish alternative still emits a concrete specialization with no reference to the abstract parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inference is skipped when an argument's simple type name coincides with an in-scope type parameter (e.g. a class imported `as U` inside `f<U>`); the name is treated as the shadowing type parameter and the call falls back to an explicit turbofish. It never mis-infers — document the conservative edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| src/Transpiler/Monomorphize/TypeInference.php | New pure unifier class. Algorithm is sound: unify walks parameter/argument trees in lock-step, assemblePrefix enforces the "concrete-prefix + defaultable-tail / no-hole" invariant, and pairArgsToParams correctly mirrors PHP's named/positional/spread/variadic argument matching. |
| src/Transpiler/Monomorphize/NewInferencePass.php | New bottom-up leaveNode visitor that infers bare new type arguments. The previous-review P1 (nested generics inferred in wrong order due to top-down traversal) is properly resolved: inner new nodes are annotated before the outer reads them via LiteralTyper::typeOfNew. |
| src/Transpiler/Monomorphize/GenericMethodCompiler.php | Adds inferCallTypeArgs/typeOf/tryInferFuncCall to the anonymous visitor, wiring inference into both the static/instance method and free-function call paths. typeParamNames is correctly accumulated through scope nesting and restored from snapshots. |
| src/Transpiler/Monomorphize/LiteralTyper.php | New context-free ExpressionTyper for scalar literals, array literals, and new X(...). typeOfNew correctly returns null for bare (un-turbofished) generics and non-concrete args, so it is safe to call during bottom-up inference. |
| src/Transpiler/Monomorphize/Compiler.php | Inserts NewInferencePass::run() at the same pipeline position in both compile() and check() — after collectDefinitions, before collectInstantiations — keeping the two modes in parity. |
| src/Transpiler/Monomorphize/ExpressionTyper.php | New interface providing the seam between the pure inference core and its two implementations (LiteralTyper and the anonymous visitor in GenericMethodCompiler). Clean design. |
| test/Transpiler/Monomorphize/TypeInferenceTest.php | New unit tests for the pure inference core with comprehensive coverage of all fallback cases (conflict, ambiguous supertype path, hole, unwitnessed required param, unknown arg type). |
| test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php | New end-to-end integration tests covering free-function, static-method, and instance-method inference, plus enclosing type-parameter guard cases and runtime execution fixtures. |
| test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php | Existing tests updated to keep the "missing type argument" error path exercised: changed from pick<T>(T $x) (now successfully inferred) to pick<T>(int $n): T (T only in return type, cannot be inferred). |
| test/fixture/check/bare_new_missing_arg/source/Box.xphp | Updated to remove the constructor T parameter so new Box() with no args cannot be inferred, keeping the missing-arg error fixture relevant post-inference. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Source .xphp files] --> B[collectDefinitions\nbuilt template registry]
B --> C[NewInferencePass.run\nbottom-up leaveNode]
C --> D{bare new X found?}
D -- yes --> E[TypeInference.infer\nunify constructor params\nvs argument types]
E --> F{complete concrete\ntuple inferred?}
F -- yes --> G[attach ATTR_GENERIC_ARGS\n+ ATTR_TEMPLATE_FQN to Name node]
F -- no --> H[leave new bare\nfall back to synthesis/error]
G --> I[collectInstantiations\nsees annotated new as explicit turbofish]
H --> I
I --> J[GenericMethodCompiler.process\nrewrite call sites]
J --> K{explicit turbofish\nor first-class callable?}
K -- explicit --> L[dispatch as normal]
K -- bare call --> M[inferCallTypeArgs\nvia typeOf ExpressionTyper]
M --> N{inferred?}
N -- yes --> O[annotate node\nrecurse rewriteFuncCall]
N -- no --> P[reportMissingTurbofishArguments\nor pad all-defaults]
O --> L
Reviews (2): Last reviewed commit: "fix(monomorphize): infer bare `new` bott..." | Re-trigger Greptile
A nested generic `new` was inferred with the wrong type argument: because the pass annotated on enterNode (top-down), an outer `new Box(new Box(5))` read its inner argument before that inner node had been annotated, so LiteralTyper saw no turbofish and typed the inner as the raw `Box` template — the outer inferred `Box<Box>` and emitted a different specialization than the explicit `new Box::<Box<int>>(new Box::<int>(5))` would. Move the inference to leaveNode (bottom-up): inner nodes are fully annotated before their enclosing `new` reads them, so an inferred nested `new` now selects the byte-identical specialization the turbofish selects. The namespace context, class stack, and scope are still in place at a `New_` leave (they pop only when the enclosing class/function leaves, which is later). Regression tests: a nested `new Box(new Box(5))` now matches the turbofish's two specializations exactly; and — pinning the scope/class stack discipline the move relies on — property inference after a nested anonymous class and parameter inference after a nested closure both resolve against the correct outer scope. Docs: correct the inference-source list. A *call* additionally infers from a statically-tracked local (assigned from a `new` or a class-returning call) and from a call whose return type is a determinable class — it reuses the monomorphizer's receiver/flow tracking — while `new` inference stays limited to the conservative set. Adds a test for the class-returning-call source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the
::<>turbofish optional wherever a generic call's ornew's type arguments are determined by the values passed.