Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
2478842
feat(closure-sig): add closure-signature type representation
math3usmartins Jul 6, 2026
e957237
feat(closure-sig): parse and erase closure signature types
math3usmartins Jul 6, 2026
ed68b37
fix(closure-sig): recognize keyword types, length-preserve erasure, b…
math3usmartins Jul 6, 2026
7ed23ce
feat(closure-sig): add closure-signature conformance engine
math3usmartins Jul 6, 2026
d0d3a1c
feat(closure-sig): extract a closure literal's declared signature
math3usmartins Jul 6, 2026
ea4840d
feat(monomorphize): gate closure-literal factories against their Clos…
math3usmartins Jul 6, 2026
dc7e71f
feat(monomorphize): re-check closure factories after grounding their …
math3usmartins Jul 6, 2026
8f9e695
fix(monomorphize): accept a closure factory whose return targets a bu…
math3usmartins Jul 6, 2026
2febcea
docs: document Closure(...) signature types and conformance checking
math3usmartins Jul 6, 2026
1b57397
feat(monomorphize): variance-check union and intersection members in …
math3usmartins Jul 6, 2026
4618b75
feat(monomorphize): structure a closure literal's union/intersection/…
math3usmartins Jul 6, 2026
cb773f1
feat(monomorphize): structure flat union/intersection/nullable in tar…
math3usmartins Jul 6, 2026
f16f4e3
feat(monomorphize): ground type parameters inside union/intersection …
math3usmartins Jul 6, 2026
0158e40
test(monomorphize): pin the sub-intersection gradual-accept paths aga…
math3usmartins Jul 6, 2026
bde729f
docs: document union, intersection, and nullable members in Closure(.…
math3usmartins Jul 6, 2026
5e8a10e
docs: sharpen closure-signature DNF limitation and a code comment
math3usmartins Jul 6, 2026
af058e6
fix(monomorphize): map anonymous template markers through the byte-of…
math3usmartins Jul 6, 2026
157c992
docs(changelog): record the generic-closure marker offset fix
math3usmartins Jul 6, 2026
e67cf4b
fix(monomorphize): scan parenthesised DNF groups as single signature …
math3usmartins Jul 6, 2026
9d5a705
docs: DNF groups in closure signatures are now gradual, not unsupported
math3usmartins Jul 6, 2026
4a43de5
fix(monomorphize): resolve fully-qualified literal types absolutely i…
math3usmartins Jul 6, 2026
6f90639
docs(changelog): record the fully-qualified literal-type conformance fix
math3usmartins Jul 6, 2026
027f64a
fix(monomorphize): bind relative namespace\Foo literal types to the n…
math3usmartins Jul 6, 2026
233facc
test(monomorphize): pin the scanner shapes two equivalence claims got…
math3usmartins Jul 6, 2026
8906615
fix(monomorphize): lower array-sugar leaves inside Closure signatures…
math3usmartins Jul 6, 2026
f77704b
docs: array-sugar leaves in closure signatures lower to array
math3usmartins Jul 6, 2026
36626e4
fix(monomorphize): consume chained array sugar whole inside Closure s…
math3usmartins Jul 6, 2026
3c15c7c
fix(monomorphize): bind relative namespace\Foo names to the namespace…
math3usmartins Jul 6, 2026
9e951af
docs(changelog): record the relative-name resolution fix
math3usmartins Jul 6, 2026
22f900b
fix(monomorphize): require a declaration header before treating `) :`…
math3usmartins Jul 6, 2026
b62aba4
docs(changelog): record the return-slot detector fix
math3usmartins Jul 6, 2026
3a5dc7b
fix(monomorphize): prove non-subtyping against built-in targets for c…
math3usmartins Jul 6, 2026
598f28b
fix(monomorphize): accept keyword method names and generic clauses in…
math3usmartins Jul 6, 2026
29a1b6f
docs(changelog): record the built-in-target conformance and enum-edge…
math3usmartins Jul 6, 2026
a001201
docs: precise wording for when a built-in conformance target can reject
math3usmartins Jul 6, 2026
f2bbaa6
test(monomorphize): pin the built-in-candidate, aliased-Stringable, a…
math3usmartins Jul 6, 2026
59cf33b
test(monomorphize): pin the static use-call reject and fix two commen…
math3usmartins Jul 6, 2026
083b5c1
fix(monomorphize): preserve newlines when stripping generic clauses a…
math3usmartins Jul 7, 2026
f815bef
fix(monomorphize): anchor anonymous-closure markers at the node's tru…
math3usmartins Jul 7, 2026
1cd8b74
fix(monomorphize): match named generic markers on the declaration nam…
math3usmartins Jul 7, 2026
00ffbeb
feat(monomorphize): loud backstop for generic markers that never bind
math3usmartins Jul 7, 2026
68995a2
feat(monomorphize): resolve php-parser Names through a qualification-…
math3usmartins Jul 7, 2026
06962d2
fix(monomorphize): honor FQ and relative spellings at generic call sites
math3usmartins Jul 7, 2026
8782127
fix(monomorphize): synthesize all-defaults instantiations for FQ and …
math3usmartins Jul 7, 2026
fa3f678
fix(monomorphize): relative names are explicit class references, neve…
math3usmartins Jul 7, 2026
e42abe7
fix(monomorphize): a use-function import is never a generic declaration
math3usmartins Jul 7, 2026
46724b4
docs: record the marker-alignment and name-resolution fixes; static a…
math3usmartins Jul 7, 2026
2ec32fa
fix(monomorphize): match generic markers byte-exact, ending same-line…
math3usmartins Jul 7, 2026
89ef9ac
feat(monomorphize): reject generic closures that are never specialized
math3usmartins Jul 7, 2026
fd430cb
test(monomorphize): make the same-line sugar pin genuinely length-cha…
math3usmartins Jul 7, 2026
11e5b7f
fix(monomorphize): a non-concrete turbofish is an attempt, not an orphan
math3usmartins Jul 7, 2026
00039c8
docs: record byte-exact markers and unspecialized-closure rejection
math3usmartins Jul 7, 2026
1014cb0
fix(monomorphize): reject a bare new of a non-defaults generic
math3usmartins Jul 7, 2026
d26525d
fix(monomorphize): emit a valid forwarding closure for a turbofish FCC
math3usmartins Jul 7, 2026
65360c9
test(monomorphize): pin the turbofish-FCC forwarding-param collision …
math3usmartins Jul 7, 2026
0044f8d
docs: record the bare-new reject and the turbofish-FCC forwarding clo…
math3usmartins Jul 7, 2026
5541076
fix(monomorphize): ground inner turbofish type args on specialization
math3usmartins Jul 7, 2026
6134ec2
fix(monomorphize): report the real source line for parse-stage reject…
math3usmartins Jul 7, 2026
0b1d94d
test(monomorphize): pin the two distinct default-reject line sites
math3usmartins Jul 7, 2026
507e760
docs: record real-line reporting for parse-stage rejections in check
math3usmartins Jul 7, 2026
69f0116
fix(monomorphize): reject a generic clause on a `use` import
math3usmartins Jul 7, 2026
128f8b8
docs: record the use-import generic-clause rejection
math3usmartins Jul 8, 2026
1879de6
feat(monomorphize): function/const-aware name resolution in Namespace…
math3usmartins Jul 8, 2026
83ad529
feat(monomorphize): collect free function and const definitions
math3usmartins Jul 8, 2026
519785e
fix(monomorphize): re-qualify free functions and consts in specialize…
math3usmartins Jul 8, 2026
01272b8
fix(monomorphize): re-qualify free symbols in gap-filled and group-im…
math3usmartins Jul 8, 2026
ab0e9eb
docs: record free-symbol re-qualification in relocated specializations
math3usmartins Jul 8, 2026
5e50c5a
fix(monomorphize): reject turbofish on a dynamically-named call inste…
math3usmartins Jul 8, 2026
e22cf58
feat(monomorphize): support keyword-named generic methods
math3usmartins Jul 8, 2026
cecd0ca
refactor(monomorphize): keep the dynamic-name reject discriminator mu…
math3usmartins Jul 8, 2026
11ec5aa
docs: record dynamic-name turbofish rejection and keyword-named gener…
math3usmartins Jul 8, 2026
c4b3169
fix(monomorphize): index group-imported class members into the use-map
math3usmartins Jul 10, 2026
495a78e
docs: changelog entry for group-imported class re-qualification
math3usmartins Jul 10, 2026
f3cb880
fix(monomorphize): specialize generic-trait adaptation operands
math3usmartins Jul 10, 2026
d79bbf2
refactor(monomorphize): make the ambiguous-trait-operand remedy actio…
math3usmartins Jul 10, 2026
68cf607
docs: record generic-trait adaptation-operand specialization
math3usmartins Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class Holder {

## `static` closures not supported

`static` **arrows** work: `static fn<T>(T $x): T => $x` specializes
exactly like a plain arrow (an arrow can never bind `$this`, so the
`static` is inert; note the rewritten dispatcher closure is technically
non-static — observable only through `Closure::bind` or reflection).
The gap below is specific to the `static function` (closure) syntax.

### ❌ What doesn't work

```php
Expand Down Expand Up @@ -112,9 +118,11 @@ file-scope generic function side-steps it.

### ✅ Workaround

Drop the `static` modifier, or lift the body to a named function:
Use an arrow, drop the `static` modifier, or lift the body to a named
function:

```php
$f = static fn<T>(T $x): T => $x; // works
$f = function<T>(T $x): T { return $x; }; // works
// or
function id<T>(T $x): T { return $x; }
Expand Down
24 changes: 22 additions & 2 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<string>('a')`): a method generic takes no inference, so the type argument must be supplied explicitly |
| `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::<string>('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box<T>` has a required parameter, instead of `new Box::<int>(...)`): the type argument takes no inference, so it must be supplied explicitly |
| `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::<int, string>` 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) |
Expand All @@ -46,12 +46,14 @@ The `json` and `github` formats tag each diagnostic with a stable code:
| `xphp.duplicate_generic_function` | the same generic function is declared in two files |
| `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) |
| `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) |
| `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but no in-scope `$var::<...>(...)` call grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable (which cannot ground it). Call it with a turbofish in the scope that declares it, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) |
| `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime |
| `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains<U : E>`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails |
| `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type |
| `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context |
| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains<U : E>`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type |
| `xphp.parse_error` | the file isn't valid PHP after the generic strip pass |
| `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible |
| `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import), reported at the offending line |
| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs |
| `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped |
| `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) |
Expand Down Expand Up @@ -377,6 +379,24 @@ Nested generic specialization exceeded depth 16. Latest registry:
<list>
```

### Closure-signature conformance

```
Closure literal does not conform to the declared `Closure(...)` type:
<detail>
```

Emitted when a closure literal is returned against a `Closure(...)` return
type it doesn't satisfy. The `<detail>` names the exact mismatch, e.g.
`parameter 1: string is not wider than int` (a parameter must be the same
as or **wider** than the target's — contravariance), `return type: A is not
a subtype of B` (the return must be the same as or **narrower** — covariance),
`by-reference-ness must match exactly`, or an arity message. See
[closure types](syntax/closure-types.md). The check only ever reports a
*provable* mismatch: an unresolved class, a still-abstract type parameter, an
untyped (⇒ `mixed`) slot, a union/intersection, or a built-in supertype is
accepted rather than falsely rejected.

### Parse / AST

```
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/runtime-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ args are spelled. Hash-collision detection at recording time fails
loudly with both colliding instantiations, the current hash length,
and a re-run command using a longer hash.

### Free functions and constants bind the template's namespace

Because a specialized body moves into `XPHP\Generated\…`, an
unqualified free-function call or constant read inside it (`helper($x)`,
`FACTOR`) would, under PHP's normal rules, look in the *generated*
namespace and then fall back to global — never the template's own
namespace. The compiler prevents this: every unqualified free-function
call and constant fetch that the template's compilation unit defines
(directly, in another file of the same namespace, or via a
`use function` / `use const` import, single or grouped) is
fully-qualified to that symbol before the class is emitted. Built-in
functions, magic constants, and any name the unit does not define are
left untouched, so PHP's global resolution still applies to them. A
specialization therefore calls exactly the free functions and constants
its template did.

## What monomorphization costs

One class file per unique instantiation. A codebase instantiating
Expand Down
8 changes: 8 additions & 0 deletions docs/syntax/classes-and-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ implementation detail.
- **Traits don't get markers** — `instanceof SomeTrait` doesn't work
in PHP, so generic traits are dropped from the emit after
specialization.
- **Generic traits work with `insteadof` / `as`** — a conflict
resolution block (`use A<int>, B<int> { A::m insteadof B; B::m as
bm; }`) rewrites its operand names to the same specializations as
the `use` list, so the adapted class loads and runs. A bare operand
that matches two different specializations of one trait (`use
A<int>, A<string>`) can't be disambiguated in an adaptation clause
and is rejected.
- **Variance edges** — when `out T` or `in T` is declared, specializations
get real `extends` chains. See [variance](variance.md).

Expand All @@ -81,5 +88,6 @@ implementation detail.
- Test fixture: `test/fixture/compile/box_generic/`
- Test fixture: `test/fixture/compile/generic_interface/`
- Test fixture: `test/fixture/compile/nested_instantiation/`
- Test fixture: `test/fixture/compile/generic_trait_adaptation/`
- Related: [methods and functions](methods-and-functions.md),
[turbofish](turbofish.md)
141 changes: 141 additions & 0 deletions docs/syntax/closure-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# `Closure(...)` signature types

xphp accepts a **closure signature type** — `Closure(int $x, string $y):
bool` — anywhere a type hint is allowed (a parameter, a return, a
property). It documents the shape of the callable a slot expects, then
**erases to a bare `\Closure`** at compile time, so the emitted PHP is
ordinary code that any PHP runtime accepts.

```php
<?php
declare(strict_types=1);

namespace App;

// A factory that returns an int-to-int closure.
function adder(int $by): Closure(int $x): int {
return fn(int $x): int => $x + $by;
}
```

compiles to:

```php
<?php
declare(strict_types=1);

namespace App;

function adder(int $by): \Closure {
return fn(int $x): int => $x + $by;
}
```

The parameter names inside the signature are documentation only (exactly
like a real closure's parameter names); only the types, order, by-reference
markers, and arity carry meaning.

A signature may be nullable (`?Closure(int): int`), may omit the return
(`Closure(int $x)` — any return accepted), and may nest
(`Closure(Closure(int): int $f): int`).

## Conformance checking

Where a closure **literal** is returned against a `Closure(...)` return
type, xphp checks that the literal actually conforms, using the same
variance PHP enforces when an inherited method overrides its prototype:

- **Parameters are contravariant** — each parameter of the literal must be
the same as or **wider** than the target's.
- **The return is covariant** — the literal's return must be the same as or
**narrower** than the target's.
- **By-reference-ness is exact**, and **arity must be compatible** (the
literal must accept every argument the target guarantees, and require no
more than the target guarantees).

```php
function makeAdder(): Closure(int $x): int {
return fn(int $x): int => $x + 1; // ✓ conforms
}

function makeBroken(): Closure(int $x): int {
return fn(string $x): int => 0; // ✗ compile error:
// parameter 1 is not wider than int
}
```

A mismatch is a compile error (`xphp compile` fails; `xphp check` reports
`xphp.closure_conformance`). See [errors](../errors.md).

### It only rejects a *provable* mismatch

The check is deliberately one-directional: it never rejects code it cannot
prove wrong. A parameter or return that is untyped (⇒ `mixed`), a class the
source set doesn't declare, a still-abstract generic type parameter, a
`self`/`static`/`parent`/`object`/`iterable`/`callable` leaf, or a nullable /
union / intersection type is **accepted**. A built-in supertype (a
`Closure(): \Throwable` target) is accepted whenever the candidate could
genuinely satisfy it — a subclass of a built-in (`\Exception`), an enum against
an interface it implements, a `__toString` class against `\Stringable`, or any
class with an unknown ancestor. Only a candidate whose **entire declared
ancestry is user code with no built-in anywhere** is provably unrelated to a
built-in target, and only that is rejected. This mirrors the RFC's runtime
leniency — lenient while unresolved, decide only when provable.

Only the return-position "factory" pattern above is checked, because that is
the one place a closure literal statically meets a `Closure(...)` target: a
default value cannot be a closure (PHP requires a constant expression), and a
closure passed through a variable or a call argument is checked gradually
(accepted).

## Generic signatures

A closure signature may reference the enclosing type parameters, and each is
**grounded** against the concrete type argument when the class specializes:

```php
class Registry<T> {
public function factory(): Closure(T $value): bool {
return fn(int $value): bool => $value > 0;
}
}

new Registry::<int>(); // target grounds to Closure(int): bool — the literal conforms
new Registry::<string>(); // target grounds to Closure(string): bool — the same literal
// is now rejected: int is not wider than string
```

## Union, intersection, and nullable members

A flat union `A|B`, intersection `A&B`, or nullable `?A` inside a signature is
variance-checked member by member, following PHP's own subtyping:

- a union in a **return** conforms when the literal's return fits **some** member
(`Closure(): int|string` accepts a `fn(): int`);
- a union in a **parameter** requires the literal to accept **every** member
(`Closure(int|string $x)` handed `fn(int $x)` fails — the literal can't take a
string);
- an intersection is checked where it is the expected (super) type — a value must
satisfy **every** member.

As everywhere, an unprovable member keeps the whole leaf gradual: a union or
intersection that mentions an unresolved class, a type parameter, or a
pseudo-type is accepted rather than falsely rejected.

An intersection used as an incoming (parameter) type also stays gradual
(accepted): an intersection of unrelated types is uninhabited, so rejecting it
would be unsound.

A **DNF** signature type (a parenthesised mix such as `(A&B)|C`) is accepted in
every position — parameter, return, nested — and behaves like any other
unresolvable leaf: it erases with the rest of the signature and stays
**gradual** (never variance-checked member by member, never the cause of a
rejection). Arity, by-ref-ness, and the other structured slots around a DNF
leaf are still checked as usual. Structuring a DNF into variance-checked
members is a possible future refinement.

An **array-sugar** leaf (`Closure(Item[] $items): int`, `Closure(): int[]`)
lowers to `array` inside a signature — the same lowering `T[]` gets everywhere
else in xphp — and participates in conformance as `array` (a gradual leaf, so
it can only ever widen acceptance; the arity around it is still checked). The
`Name<Args>[]` combination remains unsupported, in signatures as elsewhere.
18 changes: 16 additions & 2 deletions docs/syntax/closures-and-arrows.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ ref-ness is preserved end-to-end.
dispatcher, including `&` byref captures.
- The variable receiver stays unchanged at call sites — `$f::<int>(...)`
becomes `$f('T_<hash>', ...)`, not a renamed call.
- A **first-class callable** of a specialization (`$g = $f::<int>(...)`)
becomes a forwarding closure `fn(...$a) => $f('T_<hash>', ...$a)` that
routes through the dispatcher, so `$g` stays a callable with the
specialization bound (positional, variadic, and named arguments and the
closure's captures are all preserved). Empty-turbofish all-defaults FCCs
(`$f::<>(...)`) work the same way.

## Caveats

Expand All @@ -87,10 +93,18 @@ ref-ness is preserved end-to-end.

- > ⚠️ **`static` closures rejected** — `static function<T>(...)` is
rejected because generic static closures can't yet be specialized at
the call site (a capability gap, not a binding one). Use a named
generic function at file scope instead. See
the call site (a capability gap, not a binding one). A `static`
**arrow** (`static fn<T>(...)`) specializes like a plain arrow. Use
an arrow or a named generic function at file scope instead. See
[caveats](../caveats.md#static-closures-not-supported).

- > ⚠️ **Declared-but-never-called rejected** — a generic closure that no
`$var::<...>(...)` call grounds is a compile error
(`xphp.unspecialized_generic_closure`): specialization is call-site
driven, so the value would otherwise keep raw type-parameter hints and
fatal on first invocation — including when handed away as a plain
callable. Call it with a turbofish, or drop the `<...>` clause.

- > ⚠️ **Variance markers not allowed** — `out T` / `in T` are rejected on
anonymous templates. They have no stable identity for an `extends`
chain. See [caveats](../caveats.md#variance-markers-are-class-level-only).
Expand Down
1 change: 1 addition & 0 deletions docs/syntax/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ first.
| [Classes and interfaces](classes-and-interfaces.md) | `class Box<T> {}`, generic interfaces and traits, marker-interface runtime behavior |
| [Methods and functions](methods-and-functions.md) | Generic methods (static + instance), generic free functions, bare top-level |
| [Closures and arrows](closures-and-arrows.md) | `function<T>(...)`, `fn<T>(...) => ...`, captures incl. by-ref |
| [Closure types](closure-types.md) | `Closure(int $x): bool` signature types, erasure to `\Closure`, return-position conformance |
| [Type bounds](type-bounds.md) | `T : Stringable`, `T : A & B`, `T : (A & B) \| C`, F-bounded `T : Box<T>` |
| [Variance](variance.md) | `out T`, `in T`, position rules, subtype edges between specializations |
| [Defaults](defaults.md) | `T = int`, forward refs `Pair<A, B = A>`, empty turbofish `$f::<>()` |
Expand Down
9 changes: 9 additions & 0 deletions docs/syntax/turbofish.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ $id('T_<hash-of-int>', 42);
scanner requires `::<` to be adjacent.
- **Anchored to a name**: `Foo<T>(...)` (no `::`) is a PHP-side
ambiguity (`Foo < T` could be comparison) and is rejected.
- **The method name must be a literal.** A turbofish on a
*dynamically-named* method or static call — `$o->$m::<int>()`, the
nullsafe `$o?->$m::<int>()`, the static `Foo::$m::<int>()`, or a
variable-variable `$$g::<int>()` — is **rejected** with a diagnostic:
the specialized method name cannot be resolved from a value known only
at runtime. The name may be any literal identifier, **including a PHP
keyword** — `$o->list::<int>()`, `Foo::print::<int>()`, and the
matching declaration `public function list<T>(...)` all work, since PHP
permits keywords as method names.
- All call-site shapes can carry empty turbofish `::<>` when the
template is all-defaulted.
- Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted
Expand Down
6 changes: 5 additions & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
"XPHP\\FileSystem\\FilepathArray::__construct",
"XPHP\\FileSystem\\FilepathArray::filter",
"XPHP\\Transpiler\\Monomorphize\\BoundIntersection::__construct",
"XPHP\\Transpiler\\Monomorphize\\BoundUnion::__construct"
"XPHP\\Transpiler\\Monomorphize\\BoundUnion::__construct",
// ClosureLiteralSignature::extract: array_map over php-parser's
// already-0-indexed params list — the wrapper exists purely to
// prove list-ness to PHPStan; unwrapping it is a no-op.
"XPHP\\Transpiler\\Monomorphize\\ClosureLiteralSignature::extract"
]
},

Expand Down
Loading
Loading