Skip to content

feat: infer generic type arguments (make the turbofish optional) - #31

Open
math3usmartins wants to merge 9 commits into
0.3.xfrom
feat/infer-generic-type-arguments
Open

feat: infer generic type arguments (make the turbofish optional)#31
math3usmartins wants to merge 9 commits into
0.3.xfrom
feat/infer-generic-type-arguments

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

Makes the ::<> turbofish optional wherever a generic call's or new's type arguments are determined by the values passed.

math3usmartins and others added 8 commits July 29, 2026 12:23
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>
@math3usmartins math3usmartins changed the title Feat/infer generic type arguments feat: infer generic type arguments (make the turbofish optional) Jul 29, 2026
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes the ::<> turbofish optional for generic calls (new, free-function, static-method, and instance-method) by inferring type arguments from the call-site argument types. Inference is conservative and sound: it unifies parameter declared types against concrete argument types (literals, new X(...), $this->prop, typed parameters, and — in the method compiler — tracked local/return types), and falls back to the existing explicit-turbofish requirement when it cannot produce a complete, unambiguous, concrete tuple.

  • NewInferencePass (bottom-up leaveNode traversal) annotates bare new expressions before the instantiation collector sees them; GenericMethodCompiler gains a parallel inferCallTypeArgs/typeOf path for function, static, and instance method calls.
  • TypeInference is a pure unifier extracted into its own class, covering conflict detection, subtype threading via TypeHierarchy, prefix/gap rules, and a pairArgsToParams helper that mirrors PHP's own named/positional/spread/variadic argument rules.
  • Existing tests whose "missing type argument" cases relied on inferable calls were updated to use patterns where T appears only in the return type (not inferable from arguments), preserving the error-path coverage.

Confidence Score: 5/5

Safe to merge. The inference implementation is conservative and sound: it only annotates a bare call when it can fully prove a concrete type tuple, and falls back to the existing explicit-turbofish requirement otherwise, so no existing behavior changes for calls where inference cannot determine the type.

The previous review's concern about nested new being processed top-down is directly addressed by moving inference to leaveNode, with an explanatory comment. The pure TypeInference class is well unit-tested with every fallback case covered, and the end-to-end integration tests exercise compile, check, and runtime execution. Existing error-path tests were correctly updated to use non-inferable patterns (T only in the return type) rather than deleted. No data loss, soundness, or pipeline ordering issues were found.

Files Needing Attention: No files require special attention. The core TypeInference.php, NewInferencePass.php, and the anonymous visitor additions to GenericMethodCompiler.php are all covered by unit and integration tests.

Important Files Changed

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
Loading

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant