From e9c0db130e12b6f3e171bc3a7b324bf20a64ae8a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:23:57 +0000 Subject: [PATCH 1/9] feat(inference): add pure type-argument unifier and inference driver 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) --- .../Monomorphize/ExpressionTyper.php | 29 + src/Transpiler/Monomorphize/TypeInference.php | 302 +++++++++ .../Monomorphize/TypeInferenceTest.php | 639 ++++++++++++++++++ 3 files changed, 970 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ExpressionTyper.php create mode 100644 src/Transpiler/Monomorphize/TypeInference.php create mode 100644 test/Transpiler/Monomorphize/TypeInferenceTest.php diff --git a/src/Transpiler/Monomorphize/ExpressionTyper.php b/src/Transpiler/Monomorphize/ExpressionTyper.php new file mode 100644 index 0000000..80095a9 --- /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/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php new file mode 100644 index 0000000..5088876 --- /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 list $params the callee's value parameters, from the template AST + * @param list $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 list $params + * @param list $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 list $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 list $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/TypeInferenceTest.php b/test/Transpiler/Monomorphize/TypeInferenceTest.php new file mode 100644 index 0000000..f55117c --- /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]); + } +} From 5c0f512a0bf6a8ee75c555fd9e384ef841218569 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:30:07 +0000 Subject: [PATCH 2/9] feat(inference): type literals and constructions for inference 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 :: 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) --- src/Transpiler/Monomorphize/LiteralTyper.php | 76 +++++++++++ .../Monomorphize/LiteralTyperTest.php | 128 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/Transpiler/Monomorphize/LiteralTyper.php create mode 100644 test/Transpiler/Monomorphize/LiteralTyperTest.php diff --git a/src/Transpiler/Monomorphize/LiteralTyper.php b/src/Transpiler/Monomorphize/LiteralTyper.php new file mode 100644 index 0000000..f25e77f --- /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/test/Transpiler/Monomorphize/LiteralTyperTest.php b/test/Transpiler/Monomorphize/LiteralTyperTest.php new file mode 100644 index 0000000..169cee4 --- /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'))); + } +} From d421ebfcd36eedc903350edf976c1b51c6251aa1 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:53:08 +0000 Subject: [PATCH 3/9] feat(monomorphize): infer type arguments for generic calls 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) --- .../Monomorphize/GenericMethodCompiler.php | 191 ++++++++++++-- src/Transpiler/Monomorphize/TypeInference.php | 12 +- .../EnclosingParamBoundIntegrationTest.php | 37 +-- .../GenericInferenceIntegrationTest.php | 237 ++++++++++++++++++ .../inferred_call_arguments/source/Lib.xphp | 62 +++++ .../source/Models.xphp | 9 + .../inferred_call_arguments/source/Use.xphp | 21 ++ .../verify/runtime.php | 35 +++ 8 files changed, 566 insertions(+), 38 deletions(-) create mode 100644 test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php create mode 100644 test/fixture/compile/inferred_call_arguments/source/Lib.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/source/Models.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/source/Use.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7d7e9da..7bed07c 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 */ @@ -754,8 +754,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, @@ -1502,14 +1510,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 +1703,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 @@ -2639,6 +2645,155 @@ 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) { + 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); + return $return === null ? null : self::concreteOrNull(new TypeRef($return[0], $return[1])); + } + return null; + } + + /** + * 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 : []; + return self::concreteOrNull(new TypeRef($this->resolveClassName($type), $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 +2808,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/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php index 5088876..78f8515 100644 --- a/src/Transpiler/Monomorphize/TypeInference.php +++ b/src/Transpiler/Monomorphize/TypeInference.php @@ -58,8 +58,8 @@ public function __construct(private readonly TypeHierarchy $hierarchy) * - 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 list $params the callee's value parameters, from the template AST - * @param list $args the call-site arguments + * @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 @@ -233,8 +233,8 @@ private static function mentionsTypeParam(TypeRef $ref): bool * 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 list $params - * @param list $args + * @param array $params + * @param array $args * @return list */ private static function pairArgsToParams(array $params, array $args): array @@ -272,7 +272,7 @@ private static function pairArgsToParams(array $params, array $args): array /** * The parameter a named argument binds, or null when no parameter has that name. * - * @param list $params + * @param array $params */ private static function paramByName(array $params, string $name): ?Param { @@ -289,7 +289,7 @@ private static function paramByName(array $params, string $name): ?Param * trailing variadic that absorbs everything past the fixed arity, or null when the call * over-supplies a non-variadic list. * - * @param list $params + * @param array $params */ private static function paramForPosition(array $params, int $index): ?Param { diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 8a4f268..ad310ac 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 0000000..475bbbc --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -0,0 +1,237 @@ +` 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' => " $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/fixture/compile/inferred_call_arguments/source/Lib.xphp b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp new file mode 100644 index 0000000..466573a --- /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 0000000..b8b60b5 --- /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 0000000..ff5a354 --- /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'); +}; From 025cbc4e773f4a42ff4934277573ad5e49a96bb0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:48:45 +0000 Subject: [PATCH 4/9] feat(monomorphize): infer type arguments for bare `new` 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) --- src/Transpiler/Monomorphize/Compiler.php | 23 +- .../Monomorphize/NewInferencePass.php | 324 ++++++++++++++++++ .../GenericInferenceIntegrationTest.php | 254 ++++++++++++++ .../bare_new_missing_arg/source/Box.xphp | 4 +- .../bare_new_missing_arg/source/Use.xphp | 8 +- .../source/Box.xphp | 4 +- .../source/Use.xphp | 8 +- .../inferred_new_arguments/source/Lib.xphp | 39 +++ .../inferred_new_arguments/source/Models.xphp | 9 + .../inferred_new_arguments/source/Use.xphp | 16 + .../inferred_new_arguments/verify/runtime.php | 30 ++ 11 files changed, 705 insertions(+), 14 deletions(-) create mode 100644 src/Transpiler/Monomorphize/NewInferencePass.php create mode 100644 test/fixture/compile/inferred_new_arguments/source/Lib.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/source/Models.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/source/Use.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 1ea37ce..23e8e60 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/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php new file mode 100644 index 0000000..8615d34 --- /dev/null +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -0,0 +1,324 @@ + 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); + } + if ($node instanceof New_ + && $node->class instanceof Name + && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null + ) { + $this->tryInferNew($node->class, $node); + } + return null; + } + + public function leaveNode(Node $node): null + { + 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) { + $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/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index 475bbbc..ceb41aa 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -165,6 +165,260 @@ public function testConflictingArgumentsFailCompile(): void ]); } + // --- `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' => "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' => " 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 */ 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 5b5942a..92c767d 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 220602f..5b4f351 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 d9f5523..dfe0be1 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 4f98c42..25080bf 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_new_arguments/source/Lib.xphp b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp new file mode 100644 index 0000000..8a5b184 --- /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 0000000..f168269 --- /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 0000000..faf886f --- /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'); +}; From bc68a0a75587b16d0a683c7db2f49152f26b7e12 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:51:08 +0000 Subject: [PATCH 5/9] fix(monomorphize): reword the missing-turbofish diagnostic for inference 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) --- src/Transpiler/Monomorphize/GenericMethodCompiler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7bed07c..b11075b 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -2172,8 +2172,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, ); From 602a74acea5d1e865881e5fc37160bd8a40fd65b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:55:58 +0000 Subject: [PATCH 6/9] docs(inference): document optional turbofish via type-argument inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 15 +++++++++ docs/caveats.md | 65 +++++++++++++++++++++++++++++++++++++++ docs/errors.md | 2 +- docs/guides/comparison.md | 48 ++++++++++++++++++----------- docs/syntax/turbofish.md | 53 +++++++++++++++++++++++++------ 5 files changed, 154 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c2a3f..b6d67b2 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 b565be6..edf1c23 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -7,6 +7,71 @@ 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 +new Box(5); // ✓ T = int +new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new` +``` + +Inference sources are: literals, `new X(...)`, `$this->prop` (from the declared +property type), and a plain parameter reference — but only a parameter with a +*concrete* declared type that is *never reassigned* in its function. A local +variable, a value from a call, a reassigned parameter, a union-typed value, or +a value typed by a still-abstract type parameter yields no inference. + +### 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 4230b83..8e33307 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 af5b0b1..0e6404f 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 36a5bb8..3b62bb3 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -83,16 +83,49 @@ $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 conservatively: literals, `new X(...)`, `$this->prop` +(from the declared property type), and a plain parameter reference — but only a +parameter with a concrete declared type that is never reassigned in its +function. A local variable, a value returned from a call, a reassigned +parameter, a union-typed value, or a value typed by a still-abstract type +parameter is not an inference source, and such a call 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) From 21ef2d0779d737e90ec1b9dd8ba1319477a89135 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 14:17:04 +0000 Subject: [PATCH 7/9] fix(monomorphize): don't infer a call argument typed by a type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 $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::` 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::($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) --- .../Monomorphize/GenericMethodCompiler.php | 79 ++++++++++++++++++- .../GenericInferenceIntegrationTest.php | 73 +++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index b11075b..3f21059 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -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 = []; /** @@ -785,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 = []; @@ -845,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 @@ -865,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. @@ -1153,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']; @@ -1165,6 +1193,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; @@ -2683,7 +2712,9 @@ public function typeOf(Node\Expr $expr): ?TypeRef $fqn = $this->currentScopeParamTypes[$expr->name] ?? $this->currentScopeLocalTypes[$expr->name] ?? null; - if ($fqn === 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] @@ -2700,11 +2731,45 @@ public function typeOf(Node\Expr $expr): ?TypeRef } if ($expr instanceof MethodCall || $expr instanceof NullsafeMethodCall || $expr instanceof StaticCall) { $return = $this->resolveCallReturn($expr); - return $return === null ? null : self::concreteOrNull(new TypeRef($return[0], $return[1])); + 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 @@ -2737,7 +2802,13 @@ private function typeOfThisProperty(string $propName): ?TypeRef $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); /** @var list $argRefs */ $argRefs = is_array($args) ? $args : []; - return self::concreteOrNull(new TypeRef($this->resolveClassName($type), $argRefs)); + $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; diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index ceb41aa..df3f6c2 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -165,6 +165,79 @@ public function testConflictingArgumentsFailCompile(): void ]); } + // --- soundness: a call argument typed by an in-scope type parameter must not infer ----------- + + public function testCallArgTypedByEnclosingFunctionTypeParamFallsBackInCheck(): void + { + // A bare call whose argument is typed by the ENCLOSING function's type parameter must not + // infer — that type is abstract here. Inferring would emit `identity::`, 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' From f8fd214e43a3dac5c7c84525d807f7593ec41382 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 14:31:20 +0000 Subject: [PATCH 8/9] docs(inference): note the simple-name type-parameter collision fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`); 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) --- docs/caveats.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/caveats.md b/docs/caveats.md index edf1c23..984470c 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -53,6 +53,12 @@ property type), and a plain parameter reference — but only a parameter with a variable, a value from a call, a reassigned parameter, 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 From f7be3081089d274e55422371d42479e97b9c2498 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 17:18:28 +0000 Subject: [PATCH 9/9] fix(monomorphize): infer bare `new` bottom-up so nested generics match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` and emitted a different specialization than the explicit `new Box::>(new Box::(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) --- docs/caveats.md | 21 +++++-- docs/syntax/turbofish.md | 18 +++--- .../Monomorphize/NewInferencePass.php | 18 ++++-- .../GenericInferenceIntegrationTest.php | 57 +++++++++++++++++++ 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 984470c..a261238 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -43,15 +43,26 @@ 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 are: literals, `new X(...)`, `$this->prop` (from the declared -property type), and a plain parameter reference — but only a parameter with a -*concrete* declared type that is *never reassigned* in its function. A local -variable, a value from a call, a reassigned parameter, a union-typed value, or -a value typed by a still-abstract type parameter yields no inference. +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 diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 3b62bb3..f80cf95 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -117,14 +117,16 @@ 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 conservatively: literals, `new X(...)`, `$this->prop` -(from the declared property type), and a plain parameter reference — but only a -parameter with a concrete declared type that is never reassigned in its -function. A local variable, a value returned from a call, a reassigned -parameter, a union-typed value, or a value typed by a still-abstract type -parameter is not an inference source, and such a call keeps the explicit -turbofish. Generic **closure** calls (`$f($x)`) and `T[]`-typed parameters are -not yet inferred either. See +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/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php index 8615d34..d13007d 100644 --- a/src/Transpiler/Monomorphize/NewInferencePass.php +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -97,17 +97,23 @@ public function enterNode(Node $node): null 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); } - return null; - } - - public function leaveNode(Node $node): null - { if ($node instanceof FunctionLike) { array_pop($this->scopes); } @@ -267,6 +273,8 @@ private static function typeParamNamesOf(ClassLike $class): array 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; } } diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index df3f6c2..c4a91f9 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -274,6 +274,39 @@ public function testBareNewInfersFromLiteral(): void self::assertStringContainsString('Generated\\App\\Box\\T_', self::read($dist, 'Use.php')); } + public function testNestedGenericNewInfersSameSpecializationAsTurbofish(): void + { + // `new Box(new Box(5))` must infer `Box>` — 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([ @@ -304,6 +337,30 @@ public function testNonInferableBareNewThrowsInCompile(): void ]); } + public function testParameterInferenceUsesTheRightScopeAfterANestedClosure(): void + { + // After a nested closure closes, `$op` must resolve against the OUTER function's scope — + // i.e. the scope stack is popped on leave. `new Box($op)` infers Box; 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' => "