Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 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
6f1bcca
test: replace weak substring assertions with exact whole-value checks
math3usmartins Jul 10, 2026
1ea7c56
docs: restructure the how-it-works guide with per-stage pages and dia…
math3usmartins Jul 11, 2026
7cf448d
test: pin marker binding across multibyte identifiers and post-sugar …
math3usmartins Jul 11, 2026
0b2314a
docs: illustrate closure-signature variance with a class hierarchy
math3usmartins Jul 11, 2026
3275e74
fix(closure-types): reject a default value in a Closure(...) signatur…
math3usmartins Jul 11, 2026
a037b69
refactor(compiler): extract the fixed-point specialization loop into …
math3usmartins Jul 11, 2026
6c64596
fix(check): ground closure-signature conformance so check matches com…
math3usmartins Jul 11, 2026
d4813aa
feat(closure-types): clear diagnostics for unsupported closure-signat…
math3usmartins Jul 11, 2026
c493f07
docs: document the unsupported closure-signature positions as known l…
math3usmartins Jul 11, 2026
9634306
docs: note closure-type check/compile parity and unsupported-position…
math3usmartins Jul 11, 2026
780fcb8
docs: catalogue closure signature types and drop the roadmap Next sec…
math3usmartins Jul 12, 2026
82f760e
fix(parser): narrow startFilePos before indexing in the closure-in-ge…
math3usmartins Jul 12, 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
248 changes: 248 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

53 changes: 52 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 All @@ -123,6 +131,49 @@ id::<int>(42); // works

---

## Closure signature types only in parameter, return, and property slots

A `Closure(int $x): bool` signature type is accepted anywhere a plain
type hint goes — a parameter, a return, a property, or nested inside
another signature. Two positions are **not** supported: a generic type
argument (`Box<Closure(int): int>`) and a generic bound
(`class C<T : Closure(int): int>`). Each is a clear compile error:

```
A Closure(...) signature type is not supported as a generic type argument
(closure signatures are allowed only in parameter, return, and property
types). Use a bare \Closure, or introduce a named type alias.
```

A signature parameter must also carry a type and cannot have a default
value — a signature describes the callable's shape, not call-time
values.

### Why

A signature erases to a bare `\Closure` before specialization, but a
generic argument or bound participates in specialization *itself*
(naming, hashing, subtype edges), where a structural type has no
identity to anchor to. Rejecting loudly keeps the cardinal rule: no
silent miscompile. Lifting these positions is on the
[roadmap](roadmap.md) as a discovery item.

### ✅ Workaround

Use a bare `\Closure` in the generic position — you lose the
compile-time conformance check but keep a working type — or wrap the
callable in a named class:

```php
class C<T : \Closure> {} // works: bare Closure bound
$b = new Box::<\Closure>(fn() => 1); // works: bare Closure argument
```

See [closure types → known limitations](syntax/closure-types.md#known-limitations)
for the full list.

---

## Variance markers are class-level only

### ❌ What doesn't work
Expand Down
56 changes: 54 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, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), 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 @@ -124,6 +126,10 @@ In CI (GitHub Actions), one step gates the build and annotates the diff:
| `Cannot determine the receiver's type` | [Turbofish — receiver-type analysis](syntax/turbofish.md#receiver-type-analysis-instance-methods) — give the receiver a declared type. |
| `Cannot specialize the self-call` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the forward targets a non-erasable method; forward to an erasable one or move the call to a typed-receiver context. |
| `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults |
| `signature type is not supported as a generic` | [Closure types — known limitations](syntax/closure-types.md#known-limitations) — a `Closure(...)` signature can't be a generic type argument or bound; use a bare `\Closure` there. |
| `signature parameter must have a type` | [Closure types — known limitations](syntax/closure-types.md#known-limitations) — every `Closure(...)` signature parameter needs a type; use `mixed` for an unconstrained slot. |
| `signature type cannot give parameter` ... `a default value` | [Closure types](syntax/closure-types.md) — a signature describes the callable's shape; drop the `= ...` default. |
| `Closure literal does not conform` | [Closure types — conformance checking](syntax/closure-types.md#conformance-checking) |
| `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. |
| `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l <file>.xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. |

Expand Down Expand Up @@ -377,6 +383,52 @@ 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.

### Closure-signature parse rejects

Each of these is a parse-time rejection (`xphp check` reports it as
`xphp.parse_error`); see
[closure types → known limitations](syntax/closure-types.md#known-limitations).

```
A Closure(...) signature type is not supported as a generic type argument
(closure signatures are allowed only in parameter, return, and property
types). Use a bare \Closure, or introduce a named type alias.
```

```
A Closure(...) signature type is not supported as a generic bound (closure
signatures are allowed only in parameter, return, and property types). Use
a bare \Closure, or introduce a named type alias.
```

```
A Closure(...) signature parameter must have a type (untyped signature
parameters are not supported). Add a type, e.g. `Closure(int $x): int`.
```

```
A Closure(...) signature type cannot give parameter <$name|N> a default
value: a signature describes the callable's shape, not call-time values.
```

### Parse / AST

```
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,4 @@ ever has to know the generated class names.
message.
- [How it works](guides/how-it-works.md) — the compile pipeline,
end to end.
- [Roadmap](roadmap.md) — what's coming next.
- [Roadmap](roadmap.md) — what's shipped, what's in discovery.
1 change: 1 addition & 0 deletions docs/guides/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ than erasure can.
| Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ |
| Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ |
| Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ |
| Typed closure signatures (`Closure(int): bool`) | ✅ (erases to `\Closure`; literal conformance checked at compile time) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) |
| Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ |
| Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a |
Expand Down
Loading
Loading