Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to `xphp` are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **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:
a named free-function forward (`identity::<T>($v)` inside `wrap<T>`), a static call
(`self::gen::<T>()`, `Maker::wrap::<T>()` inside `Box<T>` — the idiomatic "delegate
to a shared static generic helper" shape), and an instance call (`$this->dup::<T>()`,
`$m->dup::<T>()` on a non-generic receiver, including a target declared on a generic
base class). Freshly specialized bodies are re-grounded transitively, so multi-hop
forwards and mutually recursive generics converge; a member grounded onto the
program's classes is deduplicated per unique argument tuple, and an instantiation
that first appears inside a grounded body is discovered like any other. A bound that
only becomes provable after specialization (`gen<U : Stringable>` receiving the
class's `T`) is checked per instantiation, and a strictly-growing forward chain
(`grow<T>` calling `grow::<Box<T>>`) is rejected as non-convergent
(`xphp.unconverged_method_specialization`) instead of specializing forever. See
[turbofish](docs/syntax/turbofish.md) and the
[remaining caveats](docs/caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)
(closure turbofish in generic function bodies, cross-template targets, and the
late-bound `static::`/`parent::` spellings still fail loudly).

### Fixed

- **`xphp check` / `xphp compile` parity on enclosing-parameter turbofish.** `check`
previously reported nothing for the shapes only the compile-time emitted-marker
backstop rejected — a pipeline gating on `check` alone saw green on code `compile`
refused. The validate-only pass now grounds each specialization the same way
`compile` does and collects the same diagnostics (a violated grounded bound, a
non-convergent chain, a surviving marker), located at the template's real source
line.

## [0.3.0]

### Added
Expand Down
119 changes: 66 additions & 53 deletions docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,88 +762,101 @@ so the growing type is never reached through an unbounded chain.
## Generic turbofish grounded by an enclosing type parameter

A turbofish whose type argument is supplied by an **enclosing** generic scope — a
function type parameter or a class type parameter — cannot yet be specialized. Every
such shape is rejected with a loud compile error rather than emitted as runtime-fatal
code; the representative cases below are not exhaustive (a named free-function forward
grounded by an enclosing parameter, `return identity::<T>($v)` inside `wrap<T>`, is the
same class of shape and rejected the same way). Each may be lifted in a future version.
function type parameter or a class type parameter — is grounded **per specialization**:
the call is abstract inside the template, and once the enclosing generic specializes
(`wrap::<int>`, `new Box::<int>`) the now-concrete call is dispatched to a real
specialized member or function. The shapes below compile and run:

### ❌ What doesn't work
```php
function identity<U>(U $x): U { return $x; }
function wrap<T>(T $v): T { return identity::<T>($v); } // ✅ named forward
wrap::<int>(3);

A generic **closure** grounded by an enclosing function type parameter:
final class Maker
{
public static function wrap<X>(X $v): array { return [$v]; }
}

```php
function relay<S>(S $v): S
class Box<T>
{
$inner = fn<I>(I $x): I => $x;
return $inner::<S>($v); // ❌ `S` is not concrete here
public function make(T $v): T { return self::gen::<T>($v); } // ✅ own static
public function viaMaker(T $v): array { return Maker::wrap::<T>($v); } // ✅ external static
public function twice(T $v): array { return $this->dup::<T>($v); } // ✅ own instance
public static function gen<U>(U $x): U { return $x; }
public function dup<V>(V $x): array { return [$x, $x]; }
}
```

```
Generic closure call `$inner::<S>(...)` cannot be specialized: its type argument(s)
are grounded only by an enclosing generic scope and are not concrete here …
```
Instance calls also ground on a receiver with a **non-generic** declared type
(`$maker->wrap::<T>($v)` for a `Maker $maker` parameter), and both call shapes ground
a target declared on a generic **base** class (`$this->dup::<T>` / `self::gen::<T>`
where the target lives on `Base<T>` — the member lands on the calling class's
specialization). Both `xphp check` and `xphp compile`
agree on every accept and reject below: a bound that only becomes provable after
specialization (`gen<U : Stringable>` called with the class's `T`) is checked per
instantiation in both modes.

### ❌ What still doesn't work

A **concrete** inner closure turbofish, but written **inside a generic function body**:
A generic **closure** grounded by an enclosing function type parameter — and a
**concrete** inner closure turbofish written inside a generic function body. Closure
dispatch is not re-entered per specialization:

```php
function relay<S>(S $v): S
{
$inner = fn<I>(I $x): I => $x;
return $inner::<S>($v); // ❌ xphp.unspecialized_generic_closure
}

function outer<T>(T $seed): int
{
$f = fn<U>(U $x): U => $x;
return $f::<int>(41); // ❌ the same call works at file scope, not here
}
```

A **method/static turbofish grounded by an enclosing class type parameter**:
A target declared on a **different generic template** — its specialized member belongs
on that template's own specializations, which the grounding pass must not touch:

```php
class Box<T>
class Other<S> { public static function gen<U>(U $x): U { return $x; } }
class Holder<T>
{
public function make(T $v): T { return self::gen::<T>($v); } // ❌ `T` from the class
public static function gen<U>(U $x): U { return $x; }
public function m(T $v): T { return Other::gen::<T>($v); } // ❌ cross-template
}
```

```
A generic turbofish/closure marker survived specialization into the emitted output …
[xphp.unspecialized_generic_leak]
```
The late-bound `static::` / `parent::` spellings (resolving them statically could
silently re-route a subclass or parent dispatch — rejecting loudly is the contract),
a forward to a **bare top-level** (namespace-less) generic function from inside a
generic class, and a **method-level** parameter forwarded to any generic method
(`$this->dup::<W>` inside `probe<W>`). A method-level parameter can't be forwarded
because a generic method is specialized before its class, so `W` has no concrete
value where the forward would be grounded — a non-erasable target reports
`xphp.unspecializable_self_call`, an erasable one `xphp.unspecialized_generic_leak`,
but neither is supported.

### Why
A **strictly-growing** forward chain is rejected as non-convergent rather than
compiled forever:

Variable-turbofish and method-turbofish dispatch is **call-site-driven**: a site is
specialized only when its type arguments are concrete *at that site*. When the argument
comes from an enclosing type parameter it is still abstract when the inner site is
visited, so no concrete dispatch can be built; and the concrete-inner case (`outer`)
only fails because the closure sits inside a *generic function* body, which the current
dispatch pass does not re-enter per specialization. Left un-grounded, each would emit
PHP that names a non-existent type-parameter class (`App\I`, `App\U`, or a stripped
`gen()` method) and fatal on first use. Rather than emit that, xphp fails the build: the
closure form is caught at the source seam in both `xphp check` and `xphp compile`
(`xphp.unspecialized_generic_closure`); the two shapes that reach code generation are
caught by a compile-time backstop over the emitted output
(`xphp.unspecialized_generic_leak`). Grounding these shapes so they *run* is tracked
for a later release; today the guarantee is only that they never miscompile silently.

**`xphp check` catches only the closure form.** The two shapes that surface at code
generation (`outer`, `Box::make`, and the named free-function forward above) are caught
by the emit-time backstop, which `xphp check` does not run — it validates without
emitting. So `check` reports **zero** diagnostics for those, while `compile` rejects
them loudly. A CI pipeline that gates on `xphp compile` (or runs it after `check`) is
fully covered; one that gates on `xphp check` alone will see green on code that
`compile` will reject. This is a completeness gap in `check`, never a runtime-safety
hole: no fatal-able code is ever emitted.
```php
function grow<T>(T $v): int
{
return grow::<Box<T>>(new Box::<T>($v)); // ❌ xphp.unconverged_method_specialization
}
```

### ✅ Workaround
Every rejected shape fails **loudly** — with the diagnostic named above or the
`xphp.unspecialized_generic_leak` backstop — in both `check` and `compile`; none is
ever emitted as runtime-fatal PHP.

### ✅ Workaround (for the still-rejected shapes)

Call the inner generic with an **explicit concrete** turbofish at a scope where the type
is known, or lift it out of the enclosing generic scope:
Call the inner generic with an **explicit concrete** turbofish at a scope where the
type is known, or lift it out of the enclosing generic scope:

```php
$inner = fn<I>(I $x): I => $x;
echo $inner::<int>(41); // works at file / plain-function scope

function gen<U>(U $x): U { return $x; }
Box::useGen(gen::<int>(5)); // ground the generic where the type is concrete
```
5 changes: 3 additions & 2 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ The `json` and `github` formats tag each diagnostic with a stable code:
| `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.unspecialized_generic_leak` | a last-resort compile-time safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). Raised by a small set of enclosing-parameter / generic-function-scope turbofish shapes xphp cannot yet ground — a *concrete* inner closure turbofish inside a generic function body (`$f::<int>` in `outer<T>`), or a method/static turbofish grounded by an enclosing class type parameter (`self::gen::<T>()` in `Box<T>`). Call it with an explicit concrete turbofish, or move it out of the enclosing generic scope. Compile-only; the closure-grounded-by-enclosing-parameter form is caught earlier (in both `check` and `compile`) as `xphp.unspecialized_generic_closure` |
| `xphp.unspecialized_generic_leak` | a last-resort safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). The common enclosing-parameter shapes ground and run (a named free-function forward `identity::<T>` inside `wrap<T>`; a static/instance method turbofish grounded by the enclosing class parameter, `self::gen::<T>()` / `$this->dup::<T>()` / `Maker::wrap::<T>()` in `Box<T>`); what still reaches this backstop is a *concrete* inner closure turbofish inside a generic function body (`$f::<int>` in `outer<T>`), a target declared on a *different* generic template (`Other::gen::<T>`), the late-bound `static::`/`parent::` spellings, and a forward to a bare top-level generic function from a generic class. Call the site with an explicit concrete turbofish, or move it out of the enclosing generic scope. `compile` throws; `check` collects the same diagnostic from its grounding pass. The closure-grounded-by-enclosing-parameter form is caught earlier (in both modes) as `xphp.unspecialized_generic_closure` |
| `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.unspecializable_self_call` | a `$this`-rooted self-call forwards a **method-level** type parameter to a non-erasable generic method (`$this->dup::<W>()` inside `probe<W>`): no specialization ever grounds `W`, so the call is rejected at its precise site. Forwarding the enclosing **class** parameter (`$this->dup::<T>()` inside `Box<T>`) is grounded per specialization and runs, as does forwarding to an *erasable* target (parameter used only as a direct input) |
| `xphp.unconverged_method_specialization` | a generic method/function forward chain mints a strictly deeper type argument every hop (`grow<T>` calling `grow::<Box<T>>`), so specialization can never converge; the chain is cut off at a fixed hop depth. Break the growth by forwarding a concrete turbofish |
| `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.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 |
Expand Down
14 changes: 14 additions & 0 deletions docs/syntax/methods-and-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ build time instead of fataling at runtime with "Call to undefined method".
isn't tracked — give such a local a typed parameter/property hop, or
the turbofish call fails as an undetermined receiver.

- > ⚠️ Forwarding an **enclosing class type parameter**
(`$this->dup::<T>` / `self::gen::<T>` / `Maker::wrap::<T>` inside
`Box<T>`, or `identity::<T>` inside a generic function `wrap<T>`)
grounds per specialization and runs — the class parameter becomes
concrete when the class specializes. Forwarding a **method-level**
parameter (`$this->dup::<W>` inside `probe<W>`) does **not**: a generic
method is specialized before its class, so `W` has no concrete value
where the forward would be grounded. It is a compile error regardless
of the target — `xphp.unspecializable_self_call` for a non-erasable
target, `xphp.unspecialized_generic_leak` for an erasable one.
Targets on a *different* generic template and the `static::`/`parent::`
spellings are rejected too. See
[caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter).

## See also

- Test fixture: `test/fixture/compile/generic_method/`
Expand Down
8 changes: 8 additions & 0 deletions docs/syntax/turbofish.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ than a silent pass-through that fatals at runtime.
error (`xphp.undetermined_receiver`), not a silent de-specialization.
See [caveats](../caveats.md#branching-narrowing-precision-loss).

- > ⚠️ **Enclosing type parameters as turbofish arguments** — a turbofish
grounded by an enclosing generic scope (`identity::<T>` inside `wrap<T>`,
`self::gen::<T>` / `$this->dup::<T>` / `Maker::wrap::<T>` inside `Box<T>`)
is grounded per specialization and runs. Still rejected loudly: closure
turbofish inside generic function bodies, targets on a *different*
generic template, and `static::`/`parent::` spellings. See
[caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter).

## See also

- Test fixture: `test/fixture/compile/generic_method/`
Expand Down
Loading
Loading