diff --git a/CHANGELOG.md b/CHANGELOG.md index b596807..0d0e517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,19 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 alias is a compile-time substitution — expanded into its body before specialization, with no runtime existence, so the emitted PHP never mentions the alias. Bodies may be a single - (possibly-generic) head, a **union** (`int|string`), or a **nullable** (`?Box`): + (possibly-generic) head, a **union** (`int|string`), a **nullable** (`?Box`), an + **intersection** (`A&B`), a **DNF** — a union of intersections (`(A&B)|C`) — or a + **closure signature** (`Closure(int): bool`, generic `Mapper = Closure(T): R`, + and as a nullable / union / intersection member — `?Closure(...)`, `Foo | Closure(...)`; + erased to a bare `\Closure` and conformance-checked against the signature): a single head expands in every type position (incl. as a generic argument, - `Bag`), while a union/nullable expands as the whole type of a parameter, - property, return, or class-constant slot. Aliases compose (nested and - concrete-instantiation, `type UserMap = Pair`); parameters carry - **defaults** (`type P` — a use may omit trailing defaulted arguments) - and **bounds** (`type B` — an argument that violates the bound is a - compile error; the bound may itself name an alias), like a generic class. An alias - is **file-local** — visible only in the file that declares it, like a `use` alias. - A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), + `Bag`), while a compound (union / nullable / intersection / DNF / closure + signature) expands as the whole type of a parameter, property, return, or + class-constant slot — or, as a union/intersection **bound**, all-of for an + intersection and any-of for a union. Aliases compose + (nested and concrete-instantiation, `type UserMap = Pair`); parameters + carry **defaults** (`type P` — a use may omit trailing defaulted + arguments) and **bounds** (`type B` — an argument that violates the + bound is a compile error; the bound may itself name an alias), like a generic + class. An alias is **file-local** — visible only in the file that declares it, like + a `use` alias. A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), - unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), - compound-in-non-slot (`xphp.alias_compound_in_non_slot`), or bound-violating + unsupported-body (`xphp.alias_unsupported_body` — e.g. two closures in one body, + both erasing to `\Closure`), compound-in-non-slot (`xphp.alias_compound_in_non_slot`), distribution-requiring + (`xphp.alias_compound_needs_distribution` — a union nested in an intersection), + scalar-in-intersection (`xphp.alias_scalar_in_intersection`), or bound-violating (`xphp.bound_violation`) alias is a loud error in both `xphp compile` and `xphp check`. See [type aliases](docs/syntax/type-aliases.md). - **Type-argument inference (optional turbofish).** A generic call or `new` whose diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md index 66479ad..21211c8 100644 --- a/docs/adr/0023-type-alias-declaration-syntax.md +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -61,11 +61,12 @@ and needs no runtime identity. - Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it), a bet on the declaration-form consensus. The non-generic import form (`use type … as`) could be added later as a parity synonym without disturbing this decision. -- Trade-off: the delivered scope is single-head / union / nullable bodies, **file-local** (an - alias is scoped to its file like a `use` alias, by design — see option D below); - intersection / DNF / closure bodies and compound-in-non-slot positions are still - rejected (see the [caveat](../caveats.md#type-alias-body-and-position-limits)) — a safe - subset, with the richer bodies as later work. +- Trade-off: the initial scope was single-head / union / nullable bodies, **file-local** (an + alias is scoped to its file like a `use` alias, by design — see option D below), delivering a + safe subset with the richer bodies as later work. (That later work landed in v0.4.0: + intersection, DNF, and closure-signature bodies are now supported; compound-in-non-slot + positions remain rejected. See the [caveat](../caveats.md#type-alias-body-and-position-limits) + and [roadmap](../roadmap.md) for the current state.) ### Confirmation diff --git a/docs/caveats.md b/docs/caveats.md index b44268a..b8e47d6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -94,22 +94,34 @@ behavior, only makes the type explicit. [Type aliases](syntax/type-aliases.md) are a compile-time substitution, and are **file-local by design** — an alias is visible only in the file that declares it, like a PHP `use` alias. A single head (`Ident`, `Box`), a union (`int|string`), -and a nullable (`?Box`) body are all supported; parameters may carry defaults and -bounds. Two limits remain, both on the body shape and its position. +a nullable (`?Box`), an intersection (`A & B`), a DNF (`(A & B) | C`), and a closure +signature (`Closure(int): bool`, and as a nullable / union / intersection member) body +are all supported; parameters may carry defaults and bounds. The remaining limits are +on the body shape (at most one closure per body, distribution) and the positions a +compound alias can take. ### ❌ What doesn't work ```php -type Both = A & B; // ✗ xphp.alias_unsupported_body — intersection -type Dnf = (A & B) | C; // ✗ xphp.alias_unsupported_body — DNF -type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature - -// A union / nullable alias is only usable as the WHOLE type of a slot: +type Fn = Closure(int): int; // ✓ closure signature — erases to \Closure +type N = ?Closure(int): bool; // ✓ nullable closure — erases to ?\Closure +type U = A | Closure(int): int;// ✓ union member — erases to \A|\Closure +type Two = (Closure(int): bool) | (Closure(string): int); // ✗ xphp.alias_unsupported_body — two + // closures both erase to \Closure (PHP forbids \Closure|\Closure) + +// No distribution: a union nested inside an intersection: +type Bad = (A | B) & C; // ✗ xphp.alias_compound_needs_distribution + // (write it in DNF: (A & C) | (B & C)) +type Nope = int & A; // ✗ xphp.alias_scalar_in_intersection — scalar in intersection + +// A compound alias (union / intersection / nullable / DNF) is only usable as the +// WHOLE type of a slot: type Num = int|string; function f(Num $n): void {} // ✓ whole param slot function g(Bag $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument function h(Num&Extra $x): void {} // ✗ nested in another intersection/union -$b = new Num(); // ✗ compound alias in `new` / extends / a bound +$b = new Num(); // ✗ compound alias in `new` / extends +// (as a *bound* a compound DOES expand — `type B` is all-of.) ``` ### 🔒 File-local (by design) @@ -138,19 +150,23 @@ keep one namespace per file, or fully-qualify. ### Why -The body is limited to a single head, a flat union, or a nullable because those -lower cleanly into a PHP type node. An intersection or DNF pulls in *distribution* -(`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or -anchor, so it is representable only as the whole type of a param / property / -return / class-constant slot — anywhere else it is rejected loudly rather than -mis-compiled. These are "make the safe subset solid first" trades, candidates to -lift later. File-locality, by contrast, is a deliberate choice — an alias is a -local naming convenience, like `use`, not a whole-program symbol — not a limit. +A compound body (union / intersection / nullable / DNF / closure signature) lowers +cleanly into a PHP type node, but only as the whole type of a param / property / +return / class-constant slot — it has no single identity to hash or anchor, so +anywhere else (a generic argument, `new`, `extends`, or nested in another compound) +it is rejected loudly rather than mis-compiled. What stays out: **two closures in one +body** (both erase to the same `\Closure`, and PHP forbids a duplicate `\Closure|\Closure`), +and a shape that would need **distribution** (`(A|B)&C`) — xphp requires you to write the +disjunctive normal form yourself rather than distribute (and expand) silently. These are +"make the safe subset solid first" trades. File-locality, by contrast, is a deliberate +choice — an alias is a local naming convenience, like `use`, not a whole-program symbol — +not a limit. ### ✅ Workaround -- For an intersection / DNF / closure body, write the type directly, or wrap it in - a named class or interface and alias *that*. +- A closure signature composes freely as a nullable / union / intersection member, but + a body may hold at most one — every closure erases to `\Closure`. For a `(A|B)&C` body, + write the DNF `(A&C)|(B&C)`. - Use a union/nullable alias as the whole type of a slot; write the union directly where you need it as a generic argument or nested in another compound type. - Declare an alias in each file that uses it (a zero-cost substitution), or diff --git a/docs/errors.md b/docs/errors.md index 4e2d314..4a6456f 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -60,8 +60,10 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.alias_arity` | a type-alias use whose type-argument count is outside the alias's accepted range — fewer than the required (default-less) parameters or more than it declares (`type P = …;` used as `P`; a default widens the range) | | `xphp.alias_class_collision` | a type-alias name collides with a class, interface, or trait of the same name in the same file (no silent shadowing) | | `xphp.alias_duplicate` | the same type-alias name is declared more than once in a file | -| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a flat union, or a nullable — an intersection (`A & B`), a DNF (`(A & B) \| C`), or a closure signature (`Closure(int): int`) | -| `xphp.alias_compound_in_non_slot` | a union / nullable type alias used somewhere other than the whole type of a parameter, property, return, or class-constant slot (e.g. as a generic argument or nested in another compound type) | +| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a union, an intersection, a nullable, a DNF, or a closure signature (which may be a member of a union / intersection / nullable) — e.g. **two** closures in one body (`(Closure(int): bool) \| (Closure(string): int)`), since every closure erases to the same `\Closure` and PHP forbids a duplicate `\Closure\|\Closure`, or genuine garbage | +| `xphp.alias_compound_in_non_slot` | a compound type alias (union / intersection / nullable / DNF) used somewhere other than the whole type of a parameter, property, return, or class-constant slot (e.g. as a generic argument or nested in another compound type) | +| `xphp.alias_compound_needs_distribution` | a type-alias body with a union nested inside an intersection (`(A \| B) & C`, or an intersection member that expands to a union) — not supported; rewrite it in disjunctive normal form (`(A & C) \| (B & C)`) or introduce a named type for the union | +| `xphp.alias_scalar_in_intersection` | a type-alias intersection whose member is a scalar or built-in type (`int & A`, or a `T & …` where `T` is substituted with a scalar) — PHP forbids scalars in an intersection; only class-like types can be intersected | | `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) | diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index de325cb..1efe889 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -39,7 +39,7 @@ than erasure can. | Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | | Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | -| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | +| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable / intersection / DNF / closure-signature bodies — a closure may be a nullable / union / intersection member too — parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and a body may hold at most one closure) | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | | Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples | @@ -193,8 +193,10 @@ type Pair = array{first: A, second: B}; ``` Substitution at parse time; no new nominal types. Composes naturally -with PHP's existing union types. On the roadmap as a [Generic surface -item](../roadmap.md). +with PHP's existing union types. **Shipped** — single-head, union, +nullable, intersection, DNF, and closure-signature bodies, file-local, +with parameter defaults and bounds. See the +[type aliases tour](../syntax/type-aliases.md). ### Wildcard / `*` (use-site existential) diff --git a/docs/roadmap.md b/docs/roadmap.md index 55ea8ee..630733e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,7 +52,8 @@ timeline : marker interface per template Type aliases : compile-time substitution, file-local - : single-head union and nullable bodies + : single-head union nullable intersection and DNF bodies + : closure-signature bodies erased to Closure : parameter defaults and bounds Developer experience : RFC-aligned call-site syntax @@ -237,19 +238,27 @@ upcoming one. - `type Name = Body;` and `type Name = Body;` — a compile-time substitution expanded into its body before specialization, with no runtime existence (the emitted PHP never mentions the alias). -- Single-head, **union** (`int|string`), and **nullable** (`?Box`) bodies. - A single head expands in every type position (incl. as a generic - argument, `Bag`); a union/nullable expands as the whole type of a - slot. Composes with nested and concrete-instantiation aliases. +- Single-head, **union** (`int|string`), **nullable** (`?Box`), + **intersection** (`A & B`), **DNF** (`(A & B) | C`), and + **closure-signature** (`Closure(int): bool`, generic `Mapper = + Closure(T): R`, and as a nullable / union / intersection member) bodies. A + single head expands in every type position (incl. as a generic argument, + `Bag`); a compound (union / nullable / intersection / DNF / closure + signature) expands as the whole type of a slot — a closure signature erasing + to a bare `\Closure` carrying the signature for conformance, grounded per + specialization. Composes with nested and concrete-instantiation aliases; + redundant intersection/union members are deduped, and at most one closure may + appear in a body (every one erases to `\Closure`). - Parameters carry **defaults** (`type P` — a use may omit trailing defaulted arguments) and **bounds** (`type B` — an argument that violates the bound is a compile error), like a generic class. + A union/intersection alias as a bound is any-of / all-of. - **File-local by design**: an alias is visible only in the file that declares it (like a `use` alias); declare it per file to share it. -- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body - (intersection / DNF / closure), compound-in-non-slot, and - bound-violating uses are loud compile errors in both `compile` and - `check`, each with a stable code. +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body (two + closures in one body), compound-in-non-slot, distribution-requiring + (`(A|B)&C`), scalar-in-intersection, and bound-violating uses are loud + compile errors in both `compile` and `check`, each with a stable code. - See the [type aliases](syntax/type-aliases.md) tour and the [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). diff --git a/docs/syntax/index.md b/docs/syntax/index.md index ed8c1b0..f904018 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,7 +22,7 @@ first. | [Pseudo-types](pseudo-types.md) | `self` / `static` / `parent` and the `new self::(...)` form | | [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish | | [Array sugar](array-sugar.md) | `T[]` shorthand | -| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local; union/nullable bodies, parameter defaults + bounds | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local; union / nullable / intersection / DNF / closure-signature bodies, parameter defaults + bounds | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index 5ba346e..176918a 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -65,11 +65,19 @@ no separate code path and no runtime cost. `type Name = Body;` (non-generic). The parameter list is optional; the separator is `=`. - **Bodies**: a single (possibly-generic) head (`Ident`, `Dict`), a - **union** (`int|string`), or a **nullable** (`?Box`). A single-head or - generic body expands in **every** type position, including as a generic - argument (`Bag`), `new`, `extends`, and a bound. A **union / - nullable** body expands only as the *whole* type of a parameter, - property, return, or class-constant slot (see caveats). + **union** (`int|string`), a **nullable** (`?Box`), an **intersection** + (`A & B`), a **DNF** — a union of intersections (`(A & B) | C`) — or a + **closure signature** (`Closure(int $x): bool`). A single-head or generic + body expands in **every** type position, including as a generic argument + (`Bag`), `new`, `extends`, and a bound. A **compound** body (union / + nullable / intersection / DNF / closure signature) expands only as the + *whole* type of a parameter, property, return, or class-constant slot (see + caveats) — except a union/intersection as a **bound**, where an intersection + is all-of (`type B`) and a union is any-of. +- **Closure-signature bodies** (`type Handler = Closure(int): bool;`, and + generic `type Mapper = Closure(T): R;`) erase to a bare `\Closure` in + the slot, carrying the signature for conformance checking — identical to a + directly-written `Closure(...)`. A generic one grounds its signature per use. - **Parameters** may carry **defaults** and **bounds**, like a generic class: `type P = Dict;` (a use may omit trailing defaulted arguments — `P` fills `B = A = int`), and @@ -98,10 +106,17 @@ no separate code path and no runtime cost. - `xphp.alias_class_collision` — an alias whose name collides with a class, interface, or trait of the same name (no silent shadowing). - `xphp.alias_duplicate` — the same alias name declared twice. - - `xphp.alias_unsupported_body` — an intersection / DNF / closure-signature - body (see caveats below). - - `xphp.alias_compound_in_non_slot` — a union / nullable alias used - outside a whole slot (see caveats below). + - `xphp.alias_unsupported_body` — a body that is none of the supported + shapes, e.g. **two** closures in one body (`(Closure(int): bool) | + (Closure(string): int)`), since every closure erases to the same `\Closure` + (see caveats below). + - `xphp.alias_compound_in_non_slot` — a compound alias (union / nullable / + intersection / DNF) used outside a whole slot (see caveats below). + - `xphp.alias_compound_needs_distribution` — a union nested inside an + intersection (`(A|B)&C`), which would require distribution; rewrite it in + DNF (`(A&C)|(B&C)`). + - `xphp.alias_scalar_in_intersection` — a scalar or built-in member in an + intersection (`int & A`), which PHP forbids. ## Caveats @@ -114,12 +129,26 @@ for the details and the reasons: each file that uses it, or reference the underlying type directly. (Because scoping is per-file there is no cross-file collision/duplicate to detect; same-file ones *are* caught.) -- **Intersection / DNF / closure bodies** (`A&B`, `(A&B)|C`, - `Closure(int): int`) are rejected with `xphp.alias_unsupported_body` — - write the type directly or wrap it in a named class/interface. -- **A union / nullable alias is a whole-slot type only.** As a generic - argument, in `new` / `extends` / a bound, or nested inside another - union/intersection, it is `xphp.alias_compound_in_non_slot`. +- **A closure signature may be a compound member** — nullable + (`?Closure(int): bool`), a union (`Foo | Closure(int): bool`), or an + intersection (`Foo & Closure(int): bool`) — erasing to a bare `\Closure` + inside the `?`/`|`/`&`, just like the directly-written slot type. Its + conformance is enforced under `?` and gradual as a union/intersection member + (parity with the direct forms). Only **two** closures in one body is + rejected (`xphp.alias_unsupported_body`) — both erase to `\Closure`, which + PHP forbids as a duplicate. +- **No distribution.** A union nested inside an intersection (`(A|B)&C`, or an + intersection member that expands to a union) is + `xphp.alias_compound_needs_distribution` — rewrite it in DNF. A scalar in an + intersection (`int & A`) is `xphp.alias_scalar_in_intersection`. +- **Redundant members collapse.** A duplicate member in an intersection or union + (`A & A`, or a composed `type Outer = Inner & B` where `Inner = A & B`) is + identity-preserving, so it is deduped (`A&A` ≡ `A`, `A&B&B` ≡ `A&B`) — an emitted + duplicate would be a PHP "redundant type" fatal. +- **A compound alias is a whole-slot type only.** A union / intersection / + nullable / DNF alias as a generic argument, in `new` / `extends`, or nested + inside another compound is `xphp.alias_compound_in_non_slot`. (As a *bound* + it does expand — an intersection all-of, a union any-of.) ## See also diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php new file mode 100644 index 0000000..c249f3b --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -0,0 +1,162 @@ +> $clauses union of intersection-clauses (DNF); + * non-empty, with no empty inner clause + */ + public function __construct( + public array $clauses, + ) { + } + + /** + * A single (possibly-generic) head — exactly one clause with exactly one leaf that is a `TypeRef`. + * A signature leaf is never a single head (a `\Closure` is compound, whole-slot only). Only a single + * head may expand anywhere a plain type name can (a generic argument, `new`, `extends`/`implements`, + * a bound); a compound body is representable only as the whole type of a slot. + */ + public function isSingleHead(): bool + { + return count($this->clauses) === 1 + && count($this->clauses[0]) === 1 + && $this->clauses[0][0] instanceof TypeRef; + } + + public function isCompound(): bool + { + return !$this->isSingleHead(); + } + + /** + * The sole leaf of a single-head body. Caller must have checked {@see isSingleHead()}. + */ + public function head(): TypeRef + { + $head = $this->clauses[0][0]; + assert($head instanceof TypeRef); + + return $head; + } + + /** + * The number of leaves in the whole body that erase to `\Closure` — a closure signature, or a bare + * `\Closure` TypeRef. Every one emits the identical `\Closure` type, so a body with two of them + * would emit a `\Closure|\Closure` / `\Closure&\Closure` PHP duplicate-type fatal and is rejected. + */ + public function closureLeafCount(): int + { + $count = 0; + foreach ($this->clauses as $clause) { + foreach ($clause as $leaf) { + if (self::isClosureErasing($leaf)) { + $count++; + } + } + } + + return $count; + } + + /** + * Whether a leaf erases to a bare `\Closure` — a {@see ClosureSignature}, or a `\Closure`-named + * TypeRef (case-insensitive, fully-qualified or not). + */ + private static function isClosureErasing(TypeRef|ClosureSignature $leaf): bool + { + // @infection-ignore-all UnwrapLtrim -- a bare `\Closure` TypeRef may reach here fully-qualified + // (leading backslash) or not, depending on the resolution path; the ltrim normalizes both. Its + // removal is unobservable only when the name is already backslash-free, so it is defensive. + return $leaf instanceof ClosureSignature + || (!$leaf->isGeneric() && ltrim(strtolower($leaf->name), '\\') === 'closure'); + } + + /** + * The dedup key of a leaf: every signature (and every `\Closure` TypeRef) keys to `\Closure`; a + * plain TypeRef keys to its canonical name, lowercased. PHP class-like names are case-insensitive + * (`\App\Foo` and `\App\foo` are the same class), so the key must be too — otherwise `Foo & foo` + * survives dedup and emits `\App\Foo&\App\foo`, which PHP rejects as a duplicate-type load fatal. + */ + private static function leafKey(TypeRef|ClosureSignature $leaf): string + { + return $leaf instanceof ClosureSignature ? '\\closure' : strtolower($leaf->canonical()); + } + + /** + * Dedupe the leaves of an intersection clause by key, order-preserving (keep the first occurrence). + * PHP rejects a duplicate type in an intersection ("Duplicate type … is redundant") at parse time — + * a load fatal — and `A&A` ≡ `A`, so a composed alias that reintroduces a member + * (`type Inner = A & B; type Outer = Inner & B`) collapses cleanly instead of emitting `A&B&B`. + * + * @param list $clause + * @return list + */ + public static function dedupeLeaves(array $clause): array + { + $seen = []; + $unique = []; + foreach ($clause as $leaf) { + $key = self::leafKey($leaf); + if (!isset($seen[$key])) { + // @infection-ignore-all TrueValue -- $seen is a presence set read via isset(); the + // stored value (true vs false) is never inspected, so it is unobservable. + $seen[$key] = true; + $unique[] = $leaf; + } + } + return $unique; + } + + /** + * Dedupe union clauses by their order-independent member set, order-preserving. PHP rejects a + * duplicate union arm the same way; `A|A` ≡ `A` and `A&B | B&A` ≡ `A&B`. + * + * @param list> $clauses + * @return list> + */ + public static function dedupeClauses(array $clauses): array + { + $seen = []; + $unique = []; + foreach ($clauses as $clause) { + $keys = array_map(static fn (TypeRef|ClosureSignature $leaf): string => self::leafKey($leaf), $clause); + sort($keys); + $key = implode('&', array_unique($keys)); + if (!isset($seen[$key])) { + // @infection-ignore-all TrueValue -- $seen is a presence set read via isset(); the + // stored value (true vs false) is never inspected, so it is unobservable. + $seen[$key] = true; + $unique[] = $clause; + } + } + return $unique; + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 2111818..7cf0ca5 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -58,7 +58,7 @@ * the `operands` recursion bottoms out at `array` and the * shape-narrowing happens dynamically at access sites. * - * @phpstan-type BoundDict array{kind: 'leaf', name: string, isFq: bool, args: list}|array{kind: 'and'|'or', operands: list>} + * @phpstan-type BoundDict array{kind: 'leaf', name: string, isFq: bool, args: list}|array{kind: 'closureSig', signature: ClosureSignature}|array{kind: 'and'|'or', operands: list>} */ final class XphpSourceParser { @@ -123,6 +123,8 @@ final class XphpSourceParser public const CODE_ALIAS_CLASS_COLLISION = 'xphp.alias_class_collision'; public const CODE_ALIAS_UNSUPPORTED_BODY = 'xphp.alias_unsupported_body'; public const CODE_ALIAS_COMPOUND_IN_NON_SLOT = 'xphp.alias_compound_in_non_slot'; + public const CODE_ALIAS_COMPOUND_NEEDS_DISTRIBUTION = 'xphp.alias_compound_needs_distribution'; + public const CODE_ALIAS_SCALAR_IN_INTERSECTION = 'xphp.alias_scalar_in_intersection'; /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is @@ -329,7 +331,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?AliasBody, bodyNeedsDistribution:bool, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -343,7 +345,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ + /** @var list, body:?AliasBody, bodyNeedsDistribution:bool, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -360,7 +362,7 @@ private function scanAndStrip(string $source): array // is never mistaken for a declaration) and consumes the WHOLE `type … ;` statement, // blanking it to equal-length whitespace (the alias has no runtime existence). if ($tok->id === T_STRING && $tok->text === 'type') { - $aliasParsed = self::tryParseAliasDeclaration($tokens, $i); + $aliasParsed = self::tryParseAliasDeclaration($tokens, $i, $source); if ($aliasParsed !== null) { [$aliasMarker, $semicolonIdx] = $aliasParsed; $aliasMarkers[] = $aliasMarker; @@ -1988,7 +1990,7 @@ private static function parseTypeParamList( $afterName = self::skipWs($tokens, $i); if ($afterName < $n && $tokens[$afterName]->text === ':') { $afterColon = self::skipWs($tokens, $afterName + 1); - $parsedBound = self::parseBoundExpr($tokens, $afterColon); + $parsedBound = self::parseBoundExpr($tokens, $afterColon, allowClosureSig: false, source: null); if ($parsedBound === null) { return null; } @@ -2192,6 +2194,11 @@ private static function boundContainsSelfReference(array $bound, string $paramNa && !$bound['isFq'] && $bound['args'] === []; } + // @infection-ignore-all -- a `closureSig` leaf is only produced for alias bodies (allowClosureSig), + // never for the real generic bounds this reader walks; it cannot self-reference a type parameter. + if ($bound['kind'] === 'closureSig') { + return false; + } foreach ($bound['operands'] as $operand) { /** @var BoundDict $operand — operands at the recursion boundary lose precision in the alias; the parser guarantees the shape. */ if (self::boundContainsSelfReference($operand, $paramName)) { @@ -2218,18 +2225,18 @@ private static function boundContainsSelfReference(array $bound, string $paramNa * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseBoundExpr(array $tokens, int $startIdx): ?array + private static function parseBoundExpr(array $tokens, int $startIdx, bool $allowClosureSig, ?string $source): ?array { - return self::parseOrBound($tokens, $startIdx); + return self::parseOrBound($tokens, $startIdx, $allowClosureSig, $source); } /** * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseOrBound(array $tokens, int $idx): ?array + private static function parseOrBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { - $first = self::parseAndBound($tokens, $idx); + $first = self::parseAndBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { return null; } @@ -2241,7 +2248,7 @@ private static function parseOrBound(array $tokens, int $idx): ?array if ($peek >= count($tokens) || $tokens[$peek]->text !== '|') { break; } - $next = self::parseAndBound($tokens, self::skipWs($tokens, $peek + 1)); + $next = self::parseAndBound($tokens, self::skipWs($tokens, $peek + 1), $allowClosureSig, $source); if ($next === null) { return null; } @@ -2259,9 +2266,9 @@ private static function parseOrBound(array $tokens, int $idx): ?array * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseAndBound(array $tokens, int $idx): ?array + private static function parseAndBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { - $first = self::parsePrimaryBound($tokens, $idx); + $first = self::parsePrimaryBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { return null; } @@ -2273,7 +2280,7 @@ private static function parseAndBound(array $tokens, int $idx): ?array if ($peek >= count($tokens) || $tokens[$peek]->text !== '&') { break; } - $next = self::parsePrimaryBound($tokens, self::skipWs($tokens, $peek + 1)); + $next = self::parsePrimaryBound($tokens, self::skipWs($tokens, $peek + 1), $allowClosureSig, $source); if ($next === null) { return null; } @@ -2291,14 +2298,14 @@ private static function parseAndBound(array $tokens, int $idx): ?array * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parsePrimaryBound(array $tokens, int $idx): ?array + private static function parsePrimaryBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $n = count($tokens); if ($idx >= $n) { return null; } if ($tokens[$idx]->text === '(') { - $inner = self::parseBoundExpr($tokens, self::skipWs($tokens, $idx + 1)); + $inner = self::parseBoundExpr($tokens, self::skipWs($tokens, $idx + 1), $allowClosureSig, $source); if ($inner === null) { return null; } @@ -2309,7 +2316,7 @@ private static function parsePrimaryBound(array $tokens, int $idx): ?array } return [$boundInside, $closeIdx + 1]; } - return self::parseLeafBound($tokens, $idx); + return self::parseLeafBound($tokens, $idx, $allowClosureSig, $source); } /** @@ -2320,7 +2327,7 @@ private static function parsePrimaryBound(array $tokens, int $idx): ?array * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseLeafBound(array $tokens, int $idx): ?array + private static function parseLeafBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $n = count($tokens); if ($idx >= $n || !self::isNameToken($tokens[$idx])) { @@ -2337,11 +2344,26 @@ private static function parseLeafBound(array $tokens, int $idx): ?array // a clear reject here is safe — a bare `\Closure` bound (no signature) is // untouched, and an ordinary user type is keyed out by the name check. The // opener is a plain `(` or a scalar cast token (`Closure(int)` lexes `(int)` - // as one T_INT_CAST), mirroring the closure-signature scanner. + // as one T_INT_CAST), mirroring the closure-signature scanner. In an ALIAS + // BODY ($allowClosureSig) a `Closure(...)` leaf is instead read as a signature + // leaf that erases to `\Closure`. if (ltrim($rawName, '\\') === 'Closure' && $afterName < $n && ($tokens[$afterName]->text === '(' || self::isCastToken($tokens[$afterName])) ) { + if ($allowClosureSig && $source !== null) { + $spanEnd = self::findClosureSigEnd($tokens, $afterName); + if ($spanEnd === null) { + return null; + } + // @infection-ignore-all FalseValue -- the `nullable` flag on the built signature is dead + // for conformance (never read by the validator); alias-body nullability is carried by a + // separate `null` clause, so true vs false here is unobservable. + return [ + ['kind' => 'closureSig', 'signature' => self::buildClosureSignature($tokens, $afterName, $source, false)], + $spanEnd + 1, + ]; + } throw new XphpParseException( '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.', // @infection-ignore-all Minus/IncrementInteger/DecrementInteger -- the `Closure` @@ -2449,9 +2471,9 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens - * @return array{0: array{name:string, params:list, body:?list, bytePosition:int, line:int}, 1: int}|null + * @return array{0: array{name:string, params:list, body:?AliasBody, bodyNeedsDistribution:bool, bytePosition:int, line:int}, 1: int}|null */ - private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx, string $source): ?array { // Statement-position guard. `type` always sits at index >= 1 (index 0 is the open tag), so // skipWsBack lands on a real token; the `?? null` is a defensive floor only. A `type` used as @@ -2514,17 +2536,19 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - // The body is a single head or a flat union of single heads (`?X` desugars to `X|null`); - // anything else (intersection, DNF, closure signature) yields a null body. The whole - // statement is still stripped here (so `strip()` never produces a PHP parse error); a null - // body is rejected with `xphp.alias_unsupported_body` at parse time by `buildAliasTable`. - $body = self::parseAliasBody($tokens, $bodyStart, $semiIdx); + // The body is a single head, union, nullable, intersection, or DNF (`?X` desugars to `X|null`); + // an unsupported shape (closure signature / garbage) yields a null body, and a shape needing + // distribution ((A|B)&C) yields a null body with the distribution flag set. The whole statement + // is still stripped here (so `strip()` never produces a PHP parse error); `buildAliasTable` + // raises the right diagnostic from the null body + flag. + [$body, $bodyNeedsDistribution] = self::parseAliasBody($tokens, $bodyStart, $semiIdx, $source); return [ [ 'name' => $nameTok->text, 'params' => $params, 'body' => $body, + 'bodyNeedsDistribution' => $bodyNeedsDistribution, 'bytePosition' => $tokens[$typeIdx]->pos, 'line' => $tokens[$typeIdx]->line, ], @@ -2533,51 +2557,139 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? } /** - * Parse a type-alias body (between `=`, starting at $bodyStart, and its terminator $semiIdx) as a - * flat union of single heads. Returns the union members — a single-head body is one member, and - * `?X` desugars to `[X, null]`. Returns null when the body is a shape v1/v2 does not support: an - * intersection (`&`), a parenthesised / DNF form, or a closure signature; `buildAliasTable` then - * rejects the null body with `xphp.alias_unsupported_body`. + * Parse a type-alias body (between `=`, starting at $bodyStart, and its terminator $semiIdx) into + * an {@see AliasBody} (a DNF — union of intersection-clauses). Reads a single head, a union + * (`A|B`), a nullable (`?X`), an intersection (`A&B`), and an already-DNF form (`(A&B)|C`). + * + * Returns `[body, needsDistribution]`: + * - `[AliasBody, false]` — a supported body. + * - `[null, false]` — an unsupported shape (a closure signature, or garbage); `buildAliasTable` + * raises `xphp.alias_unsupported_body`. + * - `[null, true]` — a well-formed body that would require distribution (`(A|B)&C`, a union nested + * inside an intersection); `buildAliasTable` raises `xphp.alias_compound_needs_distribution`. + * + * The diagnostics are raised in `buildAliasTable` (which has the alias FQN + line), not here. * * @param list $tokens - * @return list|null + * @return array{0: ?AliasBody, 1: bool} */ - private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?array + private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx, string $source): array { - // Leading `?` → nullable: `?` desugars to ` | null`. A `?` in front of a - // compound (`?A|B`) is illegal PHP anyway, so only a single head may follow. + // Leading `?` → nullable: `?X` ≡ `X | null`, and `?Closure(...)` ≡ `Closure(...) | null`. Only a + // single atomic head or a whole-body closure signature may follow; `?(A&B)` is a PHP parse error, + // so a `?` before anything readable here declines to unsupported (`(A&B)|null` is the supported + // spelling for that shape). // @infection-ignore-all NullSafePropertyCall -- `$bodyStart <= $semiIdx < count`, so the token // always exists; the `?? null` / `?->` is a defensive floor that never sees null. if (($tokens[$bodyStart] ?? null)?->text === '?') { - $parsed = self::parseTypeArg($tokens, self::skipWs($tokens, $bodyStart + 1)); - if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { - return null; + // `?X` ≡ `X | null`; the body after `?` must be a single leaf (a head or a closure + // signature). A compound (`?(A&B)`, `?A|B`) is a PHP parse error, so it declines. Read it + // through the shared reader so `?Closure(...)` (a signature leaf) works alongside `?X`. + $dnf = self::readAliasDnf($tokens, self::skipWs($tokens, $bodyStart + 1), $semiIdx, $source); + if ($dnf === null || $dnf === self::DNF_NEEDS_DISTRIBUTION || count($dnf) !== 1 || count($dnf[0]) !== 1) { + return [null, false]; } - return [$parsed[0], new TypeRef('null')]; + return [new AliasBody([$dnf[0], [new TypeRef('null')]]), false]; } - // Otherwise a union of single heads: `Head ( '|' Head )*`. A non-head member (an intersection - // `&`, a `(` DNF group, a closure `(`) leaves a token that is neither the terminator nor `|`, - // so the body is declined as unsupported. - $members = []; - $i = $bodyStart; - while (true) { - $parsed = self::parseTypeArg($tokens, $i); - if ($parsed === null) { - return null; - } - $members[] = $parsed[0]; - $next = self::skipWs($tokens, $parsed[1]); - if ($next === $semiIdx) { - return $members; + // Otherwise read a full `|` / `&` type expression (with `(` … `)` groups) via the shared reader. + $dnf = self::readAliasDnf($tokens, $bodyStart, $semiIdx, $source); + if ($dnf === null) { + return [null, false]; + } + if ($dnf === self::DNF_NEEDS_DISTRIBUTION) { + return [null, true]; + } + // @infection-ignore-all FalseValue -- the distribution flag rides alongside a NON-null body + // here; `buildAliasTable` reads the flag only when the body is null, so its value on a valid + // body is unobservable (true vs false selects the same accept path). + return [new AliasBody($dnf), false]; + } + + /** Sentinel: the body read fine but needs distribution (a union nested in an intersection). */ + private const DNF_NEEDS_DISTRIBUTION = 'needs-distribution'; + + /** + * Read the tokens `[$start, $semiIdx)` as a DNF body via the shared bound-expression reader — + * allowing a `Closure(...)` signature leaf (which erases to `\Closure`). Returns the clauses, `null` + * when the tokens are not a well-formed body (or don't span exactly to the terminator, or a reader + * throw), or {@see DNF_NEEDS_DISTRIBUTION} when the body needs distribution. + * + * @param list $tokens + * @return list>|self::DNF_NEEDS_DISTRIBUTION|null + */ + private static function readAliasDnf(array $tokens, int $start, int $semiIdx, string $source): array|string|null + { + try { + $parsed = self::parseBoundExpr($tokens, $start, allowClosureSig: true, source: $source); + } catch (XphpParseException) { + return null; + } + if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + return null; + } + return self::boundTreeToDnf($parsed[0]) ?? self::DNF_NEEDS_DISTRIBUTION; + } + + /** + * Normalize a bound-expression tree (union / intersection / leaf, as produced by + * {@see parseBoundExpr}) to DNF clauses — a union of intersection-clauses. A `|` concatenates its + * operands' clauses; a `&` merges its operands' leaves into one clause but only when every operand + * is itself a single clause. A union nested inside an intersection (`(A|B)&C`) would require + * distribution and returns null instead — the alias machinery rejects it loudly rather than + * distribute. + * + * @param BoundDict $tree + * @return list>|null null when the tree needs distribution + */ + private static function boundTreeToDnf(array $tree): ?array + { + if ($tree['kind'] === 'closureSig') { + return [[$tree['signature']]]; + } + if ($tree['kind'] === 'leaf') { + return [[self::boundLeafToTypeRef($tree)]]; + } + if ($tree['kind'] === 'or') { + $clauses = []; + foreach ($tree['operands'] as $operand) { + /** @var BoundDict $operand — operands lose precision at the recursion boundary */ + $sub = self::boundTreeToDnf($operand); + if ($sub === null) { + return null; + } + foreach ($sub as $clause) { + $clauses[] = $clause; + } } - // @infection-ignore-all NullSafePropertyCall -- `$next <= $semiIdx < count`, so the token - // always exists; the `?? null` / `?->` is a defensive floor that never sees null. - if (($tokens[$next] ?? null)?->text !== '|') { + return $clauses; + } + // 'and' — each operand must reduce to a single clause (no union), and their leaves merge into + // one intersection-clause; an operand that is a union needs distribution. + $merged = []; + foreach ($tree['operands'] as $operand) { + /** @var BoundDict $operand — operands lose precision at the recursion boundary */ + $sub = self::boundTreeToDnf($operand); + if ($sub === null || count($sub) > 1) { return null; } - $i = self::skipWs($tokens, $next + 1); + foreach ($sub[0] as $leaf) { + $merged[] = $leaf; + } } + return [$merged]; + } + + /** + * Build a raw (unresolved) TypeRef from a bound-expression leaf, matching {@see parseTypeArg}'s + * encoding (a fully-qualified name keeps its leading backslash) so a body read through the bound + * reader resolves identically to one read head-by-head. + * + * @param array{kind: 'leaf', name: string, isFq: bool, args: list} $leaf + */ + private static function boundLeafToTypeRef(array $leaf): TypeRef + { + return new TypeRef($leaf['isFq'] ? '\\' . $leaf['name'] : $leaf['name'], $leaf['args']); } /** @@ -2902,8 +3014,8 @@ private static function applyReplacements(string $source, array $replacements): * `xphp.alias_class_collision`. * * @param list $ast - * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @return array, body:list}> + * @param list, body:?AliasBody, bodyNeedsDistribution:bool, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:AliasBody}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -2942,10 +3054,19 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff } $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; if ($marker['body'] === null) { + if ($marker['bodyNeedsDistribution']) { + throw new XphpParseException( + "Type alias `{$fqn}` has a body that would require distribution: a union nested " + . 'inside an intersection (`(A|B)&C`) is not supported. Rewrite it in disjunctive ' + . 'normal form (`(A&C)|(B&C)`), or introduce a named type for the union.', + $marker['line'], + self::CODE_ALIAS_COMPOUND_NEEDS_DISTRIBUTION, + ); + } throw new XphpParseException( "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " - . 'or generic type, a union, or a nullable (intersection, DNF, and closure-signature ' - . 'bodies are not supported). Use a bare type or a named class.', + . 'or generic type, a union, an intersection, a nullable, a DNF, or a closure ' + . 'signature. Use a bare type or a named class.', $marker['line'], self::CODE_ALIAS_UNSUPPORTED_BODY, ); @@ -3020,7 +3141,7 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param list, body:?AliasBody, bodyNeedsDistribution:bool, bytePosition:int, line:int}> $aliasMarkers * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string @@ -3049,7 +3170,7 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:list}> $aliasTable the + * @param array, body:AliasBody}> $aliasTable the * aliases available for expansion (whole-program when injected) keyed by FQN; body is the raw (unresolved) TypeRef. * @param ?string $filepath the source file, for a captured obligation's SourceLocation; null on the standalone parse path * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations; null (inert) on the standalone parse path @@ -3071,7 +3192,7 @@ public function __construct( * Cache of resolved alias bodies keyed by alias FQN — the raw body is resolved once * (against the use-site namespace context, with the alias's params in scope) and reused. * - * @var array> + * @var array */ private array $aliasBodyCache = []; @@ -3922,11 +4043,27 @@ private function buildDefault(array $entry): ?TypeRef return $this->resolveTypeRef($entry['default']); } + /** + * Narrow a DNF leaf to a `TypeRef` for a bound. The bound path rejects a body with any + * signature leaf before this is reached, so every clause leaf is a `TypeRef`. + */ + private static function boundClauseLeaf(TypeRef|ClosureSignature $leaf): TypeRef + { + assert($leaf instanceof TypeRef); + + return $leaf; + } + /** * @param BoundDict $node */ private function buildBoundExprNode(array $node): BoundExpr { + // @infection-ignore-all -- a `closureSig` leaf is only produced for alias bodies + // (allowClosureSig), never for the real generic bounds this builder walks; defensive. + if ($node['kind'] === 'closureSig') { + throw new \LogicException('a closure-signature leaf cannot appear in a generic bound'); + } if ($node['kind'] === 'leaf') { $resolvedArgs = $this->resolveTypeRefList($node['args']); // A bound that is a bare enclosing type parameter (`U : E`, or `B : A` over an @@ -3962,10 +4099,34 @@ private function buildBoundExprNode(array $node): BoundExpr // @infection-ignore-all IncrementInteger -- buildBoundExprNode carries no source // line; a cycle/arity error while expanding a *bound* alias is reported at the // check-mode line-1 fallback whether the seed is 0 or 1, so the value is inert. - $members = $this->expandAliasToUnion(new TypeRef($fqn, $resolvedArgs), [], 0); - return count($members) === 1 - ? new BoundLeaf($members[0]) - : new BoundUnion(...array_map(static fn (TypeRef $m): BoundLeaf => new BoundLeaf($m), $members)); + $body = $this->expandAliasToDnf(new TypeRef($fqn, $resolvedArgs), [], 0); + if ($body->closureLeafCount() > 0) { + // A closure signature has no bound representation (a bound is a subtype + // constraint over named types); a body containing one is usable only as a + // whole slot. (A union / intersection alias with no closure stays a valid + // any-of / all-of bound.) + // @infection-ignore-all IncrementInteger -- as with the cycle/arity errors on + // this bound path, buildBoundExprNode carries no source line; the value is the + // check-mode line-1 fallback and is inert. + throw new XphpParseException( + "Type alias `{$fqn}` is a compound type, which is only usable as the whole " + . 'type of a parameter, property, return, or class-constant slot.', + 1, + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + // Each clause becomes a leaf (one member) or an all-of BoundIntersection (an + // `A&B` clause); the whole DNF is a lone clause or an any-of BoundUnion of the + // clause bounds. A single head is the one-clause-one-leaf case — a plain + // BoundLeaf; a union alias stays any-of, exactly as before. No clause holds a + // signature leaf here (rejected above), so every leaf is a TypeRef. + $clauseBounds = array_map( + static fn (array $c): BoundExpr => count($c) === 1 + ? new BoundLeaf(self::boundClauseLeaf($c[0])) + : new BoundIntersection(...array_map(static fn (TypeRef|ClosureSignature $l): BoundLeaf => new BoundLeaf(self::boundClauseLeaf($l)), $c)), + $body->clauses, + ); + return count($clauseBounds) === 1 ? $clauseBounds[0] : new BoundUnion(...$clauseBounds); } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); @@ -4067,11 +4228,11 @@ private function expandAliasName(Name $node): ?Node } // Expand the head AND (recursively) the arguments — an alias can appear as a generic // argument of a non-alias type (`Bag`), not just as the head. The expansion is a - // union of members: one member is a single head (leave a non-alias untouched, else - // replace); two or more is a compound (union) alias, representable only as the whole - // type of a slot (`ATTR_ALIAS_WHOLE_SLOT`). + // DNF (union of intersection-clauses): a single head (one clause, one leaf) leaves a + // non-alias untouched or replaces it; anything else is a compound alias, representable + // only as the whole type of a slot (`ATTR_ALIAS_WHOLE_SLOT`). $useRef = new TypeRef($head, $useArgs); - $members = $this->expandAliasToUnion($useRef, [], $node->getStartLine()); + $body = $this->expandAliasToDnf($useRef, [], $node->getStartLine()); // Drop the pre-expansion xphp attributes; the builders re-add the right ones (position // attributes are preserved so diagnostics still map back). $attrs = $node->getAttributes(); @@ -4082,61 +4243,145 @@ private function expandAliasName(Name $node): ?Node $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], $attrs[XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT], ); - if (count($members) === 1) { - if ($members[0]->canonical() === $useRef->canonical()) { + if ($body->isSingleHead()) { + $soleHead = $body->head(); + if ($soleHead->canonical() === $useRef->canonical()) { return null; } - return Specializer::typeRefToNode($members[0], $attrs); + return Specializer::typeRefToNode($soleHead, $attrs); } if ($node->getAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT) !== true) { throw new XphpParseException( - "Type alias `{$head}` is a union type, which is only usable as the whole type " + "Type alias `{$head}` is a compound type, which is only usable as the whole type " . 'of a parameter, property, return, or class-constant slot.', $node->getStartLine(), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, ); } - return self::unionMembersToNode($members, $attrs); + if ($body->closureLeafCount() > 1) { + // Every closure signature (and a bare `\Closure`) erases to the same `\Closure`, so + // two in one body would emit a `\Closure|\Closure` / `\Closure&\Closure` PHP + // duplicate-type fatal. (Direct `\Closure|\Closure` is itself a PHP error.) + throw new XphpParseException( + "Type alias `{$head}` has an unsupported body: a slot may contain at most one " + . 'closure — every closure signature erases to the same `\\Closure`.', + $node->getStartLine(), + XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } + return self::dnfToNode($body->clauses, $attrs, $node->getStartLine()); } /** - * Build the PHP type node for an expanded union: a `NullableType` when the sole non-null - * member is atomic (`?X` ≡ `X|null`), otherwise a `UnionType` (with a `null` member when - * the union is nullable). Members are single heads, so `?X` never wraps a compound — - * `?(A&B)` would be a fatal PHP parse error. + * Build the PHP type node for an expanded compound DNF (the whole type of a slot). A `null` + * leaf (`?X` ≡ `X|null`) is split off: when the sole non-null clause is a single atomic head + * the result is a `NullableType`, otherwise a `UnionType` with a `null` member (`?(A&B)` is a + * PHP parse error, so a compound is never `?`-wrapped). A lone intersection clause (`A&B` as a + * whole slot) emits the `IntersectionType` directly; a DNF emits a `UnionType` of clause + * nodes (PHP's union-of-intersections shape). * - * @param list $members + * @param list> $clauses * @param array $attrs */ - private static function unionMembersToNode(array $members, array $attrs): Node + private static function dnfToNode(array $clauses, array $attrs, int $line): Node { $hasNull = false; - /** @var list $nonNull */ + /** @var list> $nonNull the non-null clauses */ $nonNull = []; - foreach ($members as $m) { + foreach ($clauses as $clause) { // @infection-ignore-all UnwrapStrToLower -- resolveTypeRef already lowercases a // scalar keyword, so a `null` leaf's name is always lowercase here; strtolower is // a belt-and-suspenders guard. - if (!$m->isGeneric() && strtolower($m->name) === 'null') { + if (count($clause) === 1 && $clause[0] instanceof TypeRef && !$clause[0]->isGeneric() && strtolower($clause[0]->name) === 'null') { $hasNull = true; } else { - $nonNull[] = $m; + $nonNull[] = $clause; } } - // A single-head member always lowers to an atomic Identifier (scalar) or Name (class), - // never a compound node — so it is valid inside a UnionType and (for the `?X` case) a - // NullableType. - /** @var list $nodes */ - $nodes = array_map(static fn (TypeRef $m): Node => Specializer::typeRefToNode($m, []), $nonNull); - if ($hasNull && count($nodes) === 1) { - return new Node\NullableType($nodes[0], $attrs); + // Dedupe union arms — a duplicate arm is a PHP "redundant type" parse fatal; `A|A` ≡ `A` + // and `A&B | B&A` ≡ `A&B`. Leaf-level dedup within an intersection arm happens in + // clauseToNode. + $nonNull = AliasBody::dedupeClauses($nonNull); + if ($hasNull && count($nonNull) === 1 && count($nonNull[0]) === 1) { + // `?X` — the sole non-null member is a single atomic node (a head, or a `\Closure` + // signature for `?Closure(...)`); emit a NullableType. + return new Node\NullableType(self::leafToNode($nonNull[0][0], []), $attrs); + } + if (!$hasNull && count($nonNull) === 1) { + // A lone clause (an intersection `A&B` as the whole slot) is the slot type itself, + // not wrapped in a UnionType. + return self::clauseToNode($nonNull[0], $attrs, $line); } + $nodes = array_map(static fn (array $clause): Node => self::clauseToNode($clause, [], $line), $nonNull); if ($hasNull) { $nodes[] = new Node\Identifier('null'); } return new Node\UnionType($nodes, $attrs); } + /** + * Build the node for one DNF clause: a single-leaf clause is its atomic head (Identifier / + * Name); a multi-leaf clause is an `IntersectionType`. A scalar or built-in keyword in an + * intersection is a PHP load-time fatal (`int&B`), so it is rejected here — after + * substitution, where a type-parameter leaf's concrete type is known + * (`type Pair = T & Countable; Pair`). + * + * @param list $clause non-empty, and never the bare `null` leaf + * @param array $attrs + */ + private static function clauseToNode(array $clause, array $attrs, int $line): Node\Identifier|Name|Node\IntersectionType + { + if (count($clause) === 1) { + return self::leafToNode($clause[0], $attrs); + } + foreach ($clause as $leaf) { + // @infection-ignore-all UnwrapStrToLower -- resolveTypeRef already lowercases a + // scalar keyword before it reaches here, so strtolower is a belt-and-suspenders + // guard whose removal is unobservable. A signature leaf (a `\Closure`) is class-like. + if ($leaf instanceof TypeRef && in_array(strtolower($leaf->name), XphpSourceParser::SCALAR_TYPES, true)) { + throw new XphpParseException( + "Type alias intersection member `{$leaf->name}` is a scalar or built-in type; " + . 'only class-like types can be intersected.', + $line, + XphpSourceParser::CODE_ALIAS_SCALAR_IN_INTERSECTION, + ); + } + } + // Dedupe members — a duplicate is a PHP "redundant type" parse fatal; `A&A` ≡ `A` and a + // composed alias may reintroduce a member (`Inner=A&B; Outer=Inner&B` → `A&B`, not + // `A&B&B`). If dedup collapses to a single member, the slot type is that atomic node. + $unique = AliasBody::dedupeLeaves($clause); + if (count($unique) === 1) { + // @infection-ignore-all ReturnRemoval -- falling through builds a one-member + // IntersectionType, which nikic pretty-prints identically to the bare atomic node, + // so removing this early return is output-equivalent; the branch is a clarity guard. + return self::leafToNode($unique[0], $attrs); + } + $nodes = array_map(static fn (TypeRef|ClosureSignature $leaf): Node => self::leafToNode($leaf, []), $unique); + return new Node\IntersectionType($nodes, $attrs); + } + + /** + * Build the atomic node for one DNF leaf. A `TypeRef` lowers via {@see Specializer::typeRefToNode} + * (an `Identifier` for a scalar, a `Name` for a class). A {@see ClosureSignature} erases to a + * fully-qualified `\Closure` `Name` carrying `ATTR_CLOSURE_SIG` — read identically to a + * directly-written `Closure(...)` by the conformance validator, and grounded per specialization + * by the Specializer. + * + * @param array $attrs + */ + private static function leafToNode(TypeRef|ClosureSignature $leaf, array $attrs): Node\Identifier|Name + { + if ($leaf instanceof ClosureSignature) { + $closure = new Name\FullyQualified('Closure', $attrs); + $closure->setAttribute(XphpSourceParser::ATTR_CLOSURE_SIG, $leaf); + return $closure; + } + /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ + $atomic = Specializer::typeRefToNode($leaf, $attrs); + return $atomic; + } + /** * Recursively expand a type reference against the file-local alias table. A non-alias * head is returned with its arguments expanded; an alias head is substituted with its @@ -4150,33 +4395,37 @@ private static function unionMembersToNode(array $members, array $attrs): Node */ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef { - $members = $this->expandAliasToUnion($ref, $visited, $line); - if (count($members) !== 1) { - // A union alias reached where only a single head is representable — a generic + $body = $this->expandAliasToDnf($ref, $visited, $line); + if ($body->isCompound()) { + // A compound alias reached where only a single head is representable — a generic // argument, a `new` / turbofish / `extends` / bound, or a nested type position. throw new XphpParseException( - "Type alias `{$ref->name}` is a union type, which is only usable as the whole " + "Type alias `{$ref->name}` is a compound type, which is only usable as the whole " . 'type of a parameter, property, return, or class-constant slot.', $line, XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, ); } - return $members[0]; + return $body->head(); } /** - * Expand a type reference to its **union members** (a single-head result is one member), - * fully resolving aliases. A non-alias head yields itself with its generic arguments - * expanded (single-head — a union cannot be a generic argument, so an alias argument that - * expands to a union throws via `expandAlias`). An alias head substitutes its body's union - * members (params → arguments) and expands each recursively, concatenating — so a - * single-head alias whose body transitively resolves to a union becomes a union too, and a - * union member that is itself a union alias flattens in. A cycle or arity mismatch throws. + * Expand a type reference to its **DNF** (a union of intersection-clauses; a single-head + * result is one clause with one leaf), fully resolving aliases. A non-alias head yields + * itself with its generic arguments expanded (single-head — a compound cannot be a generic + * argument, so an alias argument that expands to a compound throws via `expandAlias`). An + * alias head substitutes its body's clauses (params → arguments) and expands each + * recursively, concatenating — so a single-head alias whose body transitively resolves to a + * union becomes a union too, and a union member that is itself a union alias flattens in. A + * cycle or arity mismatch throws. + * + * A single-leaf (union-member) body clause may expand to any DNF; a multi-leaf (intersection) + * clause merges each leaf's single-clause expansion (`&`-associativity), and a leaf that + * expands to a union inside an intersection is a distribution, rejected loudly. * * @param list $visited alias FQNs already entered on this expansion chain - * @return list */ - private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array + private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): AliasBody { // Expand each argument on the SAME visited chain — an argument that refers back to an // alias already being expanded (`type A = Bag>`) is a cycle through the @@ -4184,7 +4433,7 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, $visited, $line), $ref->args); $entry = $this->aliasTable[$ref->name] ?? null; if ($entry === null) { - return [new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]; + return new AliasBody([[new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]]); } if (in_array($ref->name, $visited, true)) { throw new XphpParseException( @@ -4199,14 +4448,56 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar foreach (array_column($entry['params'], 'name') as $k => $paramName) { $subst[$paramName] = $paddedArgs[$k]; } - $members = []; - foreach ($this->resolveAliasBody($ref->name, $entry) as $bodyMember) { - $substituted = self::substituteTypeRef($bodyMember, $subst); - foreach ($this->expandAliasToUnion($substituted, [...$visited, $ref->name], $line) as $m) { - $members[] = $m; + $aliasSubst = Substitution::of($subst); + $clauses = []; + foreach ($this->resolveAliasBody($ref->name, $entry)->clauses as $bodyClause) { + if (count($bodyClause) === 1) { + $leaf = $bodyClause[0]; + if ($leaf instanceof ClosureSignature) { + // A closure-signature leaf is terminal — it erases to `\Closure`; substitute + // the alias's params into it and keep it as its own clause. (A signature that + // is the whole body — `[[sig]]` — stays compound / whole-slot only.) + $clauses[] = [Specializer::substituteClosureSignature($leaf, $aliasSubst)]; + continue; + } + // A single-leaf clause (a union member) may expand to any DNF — its clauses + // flatten into the union. A signature reached through it (`type B = A`) flows in + // as a signature-leaf clause. + $substituted = self::substituteTypeRef($leaf, $subst); + foreach ($this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line)->clauses as $c) { + $clauses[] = $c; + } + continue; + } + // A multi-leaf (intersection) clause: expand each leaf; each must reduce to a single + // clause (an intersection cannot contain a union without distribution). A signature + // leaf is terminal (erases to `\Closure`); a TypeRef leaf that is itself an + // intersection alias contributes its own leaves (`&`-associativity); a leaf that + // expands to a union is a distribution the alias machinery rejects. + $merged = []; + foreach ($bodyClause as $leaf) { + if ($leaf instanceof ClosureSignature) { + $merged[] = Specializer::substituteClosureSignature($leaf, $aliasSubst); + } else { + $substituted = self::substituteTypeRef($leaf, $subst); + $expanded = $this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line); + if (count($expanded->clauses) > 1) { + throw new XphpParseException( + "Type alias `{$ref->name}` has a body that would require distribution: a " + . 'member of an intersection expands to a union. Rewrite it in disjunctive ' + . 'normal form, or introduce a named type for the union.', + $line, + XphpSourceParser::CODE_ALIAS_COMPOUND_NEEDS_DISTRIBUTION, + ); + } + foreach ($expanded->clauses[0] as $mergedLeaf) { + $merged[] = $mergedLeaf; + } + } } + $clauses[] = $merged; } - return $members; + return new AliasBody($clauses); } /** @@ -4224,7 +4515,7 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar * resolves in the same namespace it was declared in — no cross-file misresolution. When * cross-file generic aliases are supported, that resolution context must be revisited. * - * @param array{params:list, body:list} $entry + * @param array{params:list, body:AliasBody} $entry * @param list $paddedArgs */ private function captureAliasBoundObligation(string $fqn, array $entry, array $paddedArgs, int $line): void @@ -4257,7 +4548,7 @@ private function captureAliasBoundObligation(string $fqn, array $entry, array $p * required (default-less) parameters form a prefix (enforced by `parseTypeParamList`), so a * valid supply count is `required <= given <= total`; anything else is `xphp.alias_arity`. * - * @param array{params:list, body:list} $entry + * @param array{params:list, body:AliasBody} $entry * @param list $expandedArgs * @return list */ @@ -4305,22 +4596,30 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in * type parameters pushed so `A` / `B` become type-param references rather than qualified * class names. Cached per alias FQN. * - * @param array{params:list, body:list} $entry - * @return list + * @param array{params:list, body:AliasBody} $entry */ - private function resolveAliasBody(string $fqn, array $entry): array + private function resolveAliasBody(string $fqn, array $entry): AliasBody { // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. if (isset($this->aliasBodyCache[$fqn])) { return $this->aliasBodyCache[$fqn]; } - // Resolve each union member with the alias's own parameters in scope, then restore the - // exact prior scope stack — so the alias's params never leak into later resolution. - // Restore by saved-copy assignment (not a pop) so the restore is exact and unconditional. + // Resolve every leaf of every clause (or the closure signature) with the alias's own + // parameters in scope, then restore the exact prior scope stack — so the alias's params + // never leak into later resolution. Restore by saved-copy assignment (not a pop) so the + // restore is exact and unconditional. $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); - $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); + $resolved = new AliasBody(array_map( + fn (array $clause): array => array_map( + fn (TypeRef|ClosureSignature $leaf): TypeRef|ClosureSignature => $leaf instanceof ClosureSignature + ? $this->resolveClosureSignature($leaf) + : $this->resolveTypeRef($leaf), + $clause, + ), + $entry['body']->clauses, + )); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } @@ -4335,9 +4634,9 @@ private function resolveAliasBody(string $fqn, array $entry): array * A parameter's bound may itself name an alias (`type B`), which expands here via * `buildBoundExpr`. If that bound refers (directly or transitively) back to this alias, the * `$inFlight` guard turns the otherwise-unbounded recursion into a clean `xphp.alias_cycle` - * — the body-cycle `$visited` guard in `expandAliasToUnion` does not cover the bound axis. + * — the body-cycle `$visited` guard in `expandAliasToDnf` does not cover the bound axis. * - * @param array{params:list, body:list} $entry + * @param array{params:list, body:AliasBody} $entry * @return list */ private function resolveAliasParams(string $fqn, array $entry, int $line): array diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 5f8ed30..7bf0490 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -54,6 +54,44 @@ public function testTypeAliasesExpandAndRunAtRuntime(): void } } + #[RunInSeparateProcess] + public function testIntersectionAndDnfBodiesExpandAndRunAtRuntime(): void + { + // Execute the emitted output: an intersection body (`A&B`) and a DNF body (`(A&B)|C`) lower to + // real PHP type nodes. That the program loads (a malformed node would fatal at class-load) and + // the native type check accepts the conforming objects proves the emission is valid. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/intersection_alias/source', + 'inter', + ); + try { + $fixture->registerAutoload('App\\Inter'); + $runtime = require __DIR__ . '/../../fixture/compile/intersection_alias/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testClosureSignatureBodiesExpandAndRunAtRuntime(): void + { + // Execute the emitted output: a closure-signature body (`Handler = Closure(int): bool`) and a + // generic one (`Mapper = Closure(T): R`) erase to bare `\Closure` slots. That the program + // loads and a closure flowing through each slot is invoked proves the erasure + dispatch. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_sig_alias/source', + 'clo', + ); + try { + $fixture->registerAutoload('App\\Clo'); + $runtime = require __DIR__ . '/../../fixture/compile/closure_sig_alias/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + public function testGenericAliasExpandsToItsBodySpecialization(): void { $use = self::read($this->compile([ @@ -177,6 +215,250 @@ public function testUnionAndNullableBodiesExpandInWholeSlots(): void self::assertStringContainsString('function g(int|string $x): int|string|null', $use); } + public function testIntersectionAndDnfBodiesExpandInWholeSlots(): void + { + // An intersection body expands into a slot as a real `A&B`; a DNF body as `(A&B)|C`; and a + // nested intersection alias flattens by `&`-associativity into `A&B&C` — all as the WHOLE type + // of a param / property / return slot. + $use = self::read($this->compile([ + 'Use.xphp' => "compile([ + 'Use.xphp' => "compile([ + 'Use.xphp' => " = Closure(T): R;\nclass Svc {\n public Handler \$h;\n public FqHandler \$fq;\n public ClosureOrA \$mx;\n public function run(Handler \$c): bool { return \$c(1); }\n public function map(Mapper \$f): string { return \$f(1); }\n}\n", + ]), 'Use.php'); + + self::assertStringContainsString('public \\Closure $h', $use); + self::assertStringContainsString('function run(\\Closure $c): bool', $use); + self::assertStringContainsString('function map(\\Closure $f): string', $use); + // A fully-qualified `\Closure(...)` body is recognized (the `\` is stripped) and erases the same. + self::assertStringContainsString('public \\Closure $fq', $use); + // A bare `\Closure` combined in a UNION is NOT a closure-signature body — it stays a union + // (`\Closure|\App\A`); only a `Closure(...)` signature spanning the whole body erases specially. + self::assertStringContainsString('public \\Closure|\\App\\A $mx', $use); + // The alias name never appears in the emitted PHP. + self::assertStringNotContainsString('Handler', $use); + self::assertStringNotContainsString('Mapper', $use); + } + + public function testClosureSignatureConformanceRidesTheAliasInBothModes(): void + { + // The signature rides the alias: a closure literal returned against a `Handler` return type is + // conformance-checked against `Closure(int): bool`, not just the bare `\Closure`. A provable + // mismatch fails in both modes; a conforming literal compiles. + $header = " $header . "function make(): Handler { return fn(int \$x): int => \$x; }\n"]; + self::assertRejected($this->check($bad), 'xphp.closure_conformance', 'is not a subtype of bool'); + // Conformance violations are raised AFTER parsing (a RuntimeException), like a bound violation. + $this->assertCompileThrowsRuntime($bad, 'is not a subtype of bool'); + + $ok = ['C.xphp' => $header . "function make(): Handler { return fn(int \$x): bool => true; }\n"]; + self::assertFalse($this->check($ok)->hasErrors(), 'a conforming closure literal against the aliased signature compiles'); + } + + public function testGenericClosureSignatureAliasGroundsPerSpecialization(): void + { + // A generic closure-sig alias whose signature references an enclosing class type parameter + // (`Mapper` inside `class Box`) grounds per specialization: a factory returning a + // conforming closure compiles; one returning a mismatched closure is a conformance error. + $header = " = Closure(T): R;\n"; + + $ok = ['C.xphp' => $header . "class Box { public function make(): Mapper { return fn(\$x): bool => true; } }\nclass Driver { public function go(): void { \$b = new Box::(); } }\n"]; + self::assertFalse($this->check($ok)->hasErrors(), 'a grounded closure-sig alias with a conforming factory compiles'); + + $bad = ['C.xphp' => $header . "class Box { public function make(): Mapper { return fn(\$x): string => 'no'; } }\nclass Driver { public function go(): void { \$b = new Box::(); } }\n"]; + self::assertRejected($this->check($bad), 'xphp.closure_conformance', 'is not a subtype of'); + } + + public function testTransitiveClosureSignatureAliasPropagatesInASlotAndRejectsElsewhere(): void + { + // A single-head alias to a closure-sig alias (`type B = A`) IS that signature: in a whole slot + // it erases to `\Closure` and its conformance rides through; combined in a union, or used as a + // bound, it rejects loudly (never silently dropping the `\Closure` type — which would emit + // un-loadable / untyped PHP). + $header = "compile([ + 'C.xphp' => $header . "class Svc { public B \$h; public function make(): B { return fn(int \$x): bool => true; } }\n", + ]), 'C.php'); + self::assertStringContainsString('public \\Closure $h', $use); + self::assertStringContainsString('function make(): \\Closure', $use); + + $bad = ['C.xphp' => $header . "function make(): B { return fn(int \$x): int => \$x; }\n"]; + self::assertRejected($this->check($bad), 'xphp.closure_conformance', 'is not a subtype of bool'); + $this->assertCompileThrowsRuntime($bad, 'is not a subtype of bool'); + + // A closure signature combined with another type IN THE ALIAS BODY now expands (the closure + // erases to a bare `\Closure` member) — see testClosureSignatureInCompoundAliasBodies. (A + // compound *alias* like `A` still can't be a union member in a slot — that is `Foo|A`, an + // xphp.alias_compound_in_non_slot, unchanged from WI-01.) + $combined = self::read($this->compile([ + 'C.xphp' => $header . "type U = Foo | Closure(int): bool;\ntype I = Foo & Closure(int): bool;\nclass Svc { public U \$u; public I \$i; }\n", + ]), 'C.php'); + self::assertStringContainsString('public \\App\\Foo|\\Closure $u', $combined); + self::assertStringContainsString('public \\App\\Foo&\\Closure $i', $combined); + + // A closure-sig alias still has no bound representation — compound-in-non-slot. + $bound = ['C.xphp' => $header . "class Bag {}\nfunction f(): int { \$b = new Bag::(); return 1; }\n"]; + self::assertRejected($this->check($bound), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, 'the whole type of a parameter'); + $this->assertCompileThrows($bound, 'the whole type of a parameter'); + } + + public function testClosureSignatureInCompoundAliasBodies(): void + { + // A closure signature may be a MEMBER of a compound alias body — nullable, union, intersection — + // erasing to a bare `\Closure` inside the `?`/`|`/`&` node, exactly as the directly-written slot + // types do. Conformance parity: `?Closure` is enforced (the validator unwraps NullableType) while + // a union/intersection member is gradual (it does not descend) — matching the direct forms. + $header = "compile([ + 'C.xphp' => $header . "type N = ?Closure(int): bool;\ntype U = Foo | Closure(int): bool;\ntype I = Foo & Closure(int): bool;\ntype H = Closure(int): bool;\ntype NAlias = ?H;\nclass Svc { public N \$n; public U \$u; public I \$i; public NAlias \$na; }\n", + ]), 'C.php'); + self::assertStringContainsString('public ?\\Closure $n', $use); + self::assertStringContainsString('public \\App\\Foo|\\Closure $u', $use); + self::assertStringContainsString('public \\App\\Foo&\\Closure $i', $use); + // A single-head alias to a closure-sig alias, nullable: `?H` ≡ `?\Closure`. + self::assertStringContainsString('public ?\\Closure $na', $use); + + // `?Closure` conformance IS enforced (a violating literal returned against it fails). + $badNullable = ['C.xphp' => $header . "type N = ?Closure(int): bool;\nfunction make(): N { return fn(int \$x): int => \$x; }\n"]; + self::assertRejected($this->check($badNullable), 'xphp.closure_conformance', 'is not a subtype of bool'); + // A union member is gradual (not enforced) — parity with a directly-written `Foo|Closure(...)`. + $unionGradual = ['C.xphp' => $header . "type U = Foo | Closure(int): bool;\nfunction f(U \$x): int { return 1; }\n"]; + self::assertFalse($this->check($unionGradual)->hasErrors(), 'a closure in a union member is gradual, like the direct form'); + + // Two closures in one body would emit a `\Closure|\Closure` PHP duplicate-type fatal — rejected. + foreach ([ + 'two-sigs' => 'type Bad = (Closure(int): bool) | (Closure(string): int);', + 'sig-and-bare' => 'type Bad = \\Closure | Closure(int): bool;', + ] as $decl) { + $twoClosure = ['C.xphp' => $header . "{$decl}\nclass Svc { public Bad \$x; }\n"]; + self::assertRejected($this->check($twoClosure), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, 'at most one closure'); + $this->assertCompileThrows($twoClosure, 'at most one closure'); + } + + // A scalar next to a closure in an intersection still rejects on the scalar (PHP forbids it). + $scalar = ['C.xphp' => $header . "type Bad = int & Closure(int): bool;\nclass Svc { public Bad \$x; }\n"]; + self::assertRejected($this->check($scalar), XphpSourceParser::CODE_ALIAS_SCALAR_IN_INTERSECTION, 'scalar or built-in type'); + } + + public function testClosureSignatureAliasInNonSlotIsRejectedInBothModes(): void + { + // A closure-signature alias is a whole-slot type only — as a generic argument or in `new` it is + // `xphp.alias_compound_in_non_slot` (never an un-`new`-able `new \Closure()` or a miscompile). + $header = " {}\ntype Handler = Closure(int): bool;\n"; + $needle = 'the whole type of a parameter, property, return, or class-constant slot'; + foreach ([ + 'generic-arg' => 'function f(Bag $z): int { return 1; }', + 'new' => 'function f(): int { $x = new Handler(); return 1; }', + ] as $body) { + $files = ['C.xphp' => $header . $body . "\n"]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, $needle); + $this->assertCompileThrows($files, $needle); + } + } + + public function testCompoundNeedsDistributionIsRejectedInBothModes(): void + { + // A union nested inside an intersection would require distribution ((A|B)&C → (A&C)|(B&C)), + // which the alias machinery rejects loudly rather than distribute — both when written directly + // and when a union alias lands inside an intersection at expansion. Never a silent miscompile. + // The full messages are asserted (each differs) so a reworded / reordered diagnostic is caught. + $header = " $header . "type Bad = (A | B) & C;\nfunction f(Bad \$x): int { return 1; }\n"]; + $directlyMessage = 'has a body that would require distribution: a union nested inside an ' + . 'intersection (`(A|B)&C`) is not supported. Rewrite it in disjunctive normal form ' + . '(`(A&C)|(B&C)`), or introduce a named type for the union.'; + self::assertRejected($this->check($directly), XphpSourceParser::CODE_ALIAS_COMPOUND_NEEDS_DISTRIBUTION, $directlyMessage); + $this->assertCompileThrows($directly, $directlyMessage); + + $expansion = ['C.xphp' => $header . "type U = A | B;\ntype Bad = U & C;\nfunction f(Bad \$x): int { return 1; }\n"]; + $expansionMessage = 'has a body that would require distribution: a member of an intersection ' + . 'expands to a union. Rewrite it in disjunctive normal form, or introduce a named type ' + . 'for the union.'; + self::assertRejected($this->check($expansion), XphpSourceParser::CODE_ALIAS_COMPOUND_NEEDS_DISTRIBUTION, $expansionMessage); + $this->assertCompileThrows($expansion, $expansionMessage); + } + + public function testScalarInIntersectionIsRejectedInBothModes(): void + { + // An intersection with a scalar / built-in member is a PHP load-time fatal (`int&B`), so it is + // rejected — including when the scalar arrives only via a type-parameter substitution + // (`Pair`), which is why the check runs at emit time, after substitution. + $header = " "type Bad = int & A;\nfunction f(Bad \$x): int { return 1; }", + 'substituted-scalar' => "type Pair = T & Countable2;\nfunction f(Pair \$x): int { return 1; }", + ] as $body) { + $files = ['C.xphp' => $header . $body . "\n"]; + $message = 'is a scalar or built-in type; only class-like types can be intersected.'; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_SCALAR_IN_INTERSECTION, $message); + $this->assertCompileThrows($files, $message); + } + } + + public function testIntersectionAliasBoundIsAllOf(): void + { + // An intersection alias used as a type-parameter bound is all-of (`T : A&B`): an argument + // implementing both members is accepted, one implementing only a single member is rejected — + // not flattened to an any-of union bound (which would unsoundly accept the single-member type). + $header = " { public function __construct(public T \$v) {} }\nclass AB implements A, B {}\nclass OnlyA implements A {}\n"; + $ok = ['C.xphp' => $header . "function f(): int { \$b = new Box::(new AB()); return 1; }\n"]; + self::assertFalse($this->check($ok)->hasErrors(), 'an argument implementing every member of the intersection bound compiles'); + + $bad = ['C.xphp' => $header . "function f(): int { \$b = new Box::(new OnlyA()); return 1; }\n"]; + self::assertRejected($this->check($bad), 'xphp.bound_violation', 'does not satisfy'); + $this->assertCompileThrowsRuntime($bad, 'does not satisfy'); + } + public function testCompoundAliasInNonSlotPositionsAreRejectedInBothModes(): void { // A union alias is representable only as the WHOLE type of a param / property / return / @@ -189,6 +471,8 @@ public function testCompoundAliasInNonSlotPositionsAreRejectedInBothModes(): voi 'extends' => "type Num = int|string;\nclass C extends Num {}", 'nested-in-nullable' => "type Num = int|string;\nfunction f(?Num \$x): int { return 1; }", 'nested-in-union' => "class Extra {}\ntype Num = int|string;\nfunction f(Num|Extra \$x): int { return 1; }", + 'intersection-generic-arg' => "class Bag {}\ninterface A {} interface B {}\ntype Both = A & B;\nfunction f(Bag \$x): int { return 1; }", + 'intersection-nested' => "interface A {} interface B {} class Extra {}\ntype Both = A & B;\nfunction f(Both|Extra \$x): int { return 1; }", ] as $body) { $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, $needle); @@ -520,16 +804,35 @@ public function testASelfReferentialGenericAliasBoundIsRejectedAsACycleNotACrash public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { - // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear - // diagnostic — not a raw PHP parse error. (Union and nullable bodies ARE supported — see the - // union tests.) The full message is asserted so a reworded or truncated diagnostic is caught. + // The remaining unsupported bodies are recognized (stripped) but rejected with a clear + // diagnostic — not a raw PHP parse error. (Single-head, union, nullable, intersection, DNF, and + // closure-signature bodies ARE supported — see their tests.) The full message is asserted so a + // reworded or truncated diagnostic is caught. + $message = 'single class or generic type, a union, an intersection, a nullable, a DNF, or a ' + . 'closure signature. Use a bare type'; + foreach ([ + // A body the bound-expression reader cannot read (leads with `|`) — declined, not crashed. + 'malformed' => 'type Bad = |A;', + // A complete closure signature followed by a trailing token — the span must cover the WHOLE + // body, so trailing junk declines rather than silently dropping it. + 'closure-trailing-token' => 'type Bad = Closure(int) A;', + ] as $body) { + $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); + $this->assertCompileThrows($files, $message); + } + } + + public function testACyclicIntersectionAliasIsRejectedAsACycleNotAnOverflow(): void + { + // A cycle THROUGH an intersection member — `type X = Y & A; type Y = X & B` — must be caught by + // the accumulated visited-set as an xphp.alias_cycle, not recurse without bound. This pins the + // visited threading in the multi-leaf (intersection) expansion path. $files = [ - 'C.xphp' => " "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); - $this->assertCompileThrows($files, $message); + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); } public function testNoSpaceAliasBodyExpands(): void diff --git a/test/fixture/compile/closure_sig_alias/source/Registry.xphp b/test/fixture/compile/closure_sig_alias/source/Registry.xphp new file mode 100644 index 0000000..efd11bc --- /dev/null +++ b/test/fixture/compile/closure_sig_alias/source/Registry.xphp @@ -0,0 +1,55 @@ + = Closure(T): R; +// A single-head alias to a closure-signature alias must itself behave as that signature (it must +// emit `\Closure`, not un-loadable / untyped PHP). +type Aliased = Handler; +// A closure signature as a MEMBER of a compound body: a nullable closure erases to `?\Closure`. +type MaybeCheck = ?Closure(int $x): bool; + +class Registry +{ + public Handler $check; + public Aliased $alias; + public MaybeCheck $maybe; + + public function __construct(Handler $check, Aliased $alias, MaybeCheck $maybe) + { + $this->check = $check; + $this->alias = $alias; + $this->maybe = $maybe; + } + + public function viaMaybe(int $n): bool + { + return ($this->maybe)($n); + } + + public function run(int $n): bool + { + return ($this->check)($n); + } + + public function viaAlias(int $n): bool + { + return ($this->alias)($n); + } + + public function map(Mapper $f, int $n): string + { + return $f($n); + } +} + +$reg = new Registry(fn(int $x): bool => $x > 0, fn(int $x): bool => $x < 0, fn(int $x): bool => $x === 0); +$ok = $reg->run(5); +$aliasOk = $reg->viaAlias(-2); +$maybeOk = $reg->viaMaybe(0); +$mapped = $reg->map(fn(int $x): string => "n{$x}", 3); diff --git a/test/fixture/compile/closure_sig_alias/verify/runtime.php b/test/fixture/compile/closure_sig_alias/verify/runtime.php new file mode 100644 index 0000000..663757a --- /dev/null +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -0,0 +1,34 @@ + = Closure(T): R`) erase to bare `\Closure` slots. The + * non-negotiable gate is that the emitted program LOADS (the erased `\Closure` is a real type) and + * that a closure flowing through each slot is invoked and returns the right value. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user file + * isn't PSR-4, so require it directly; the top-level statements run on require and expose the values. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Registry.php'; + + // The `Handler` (`Closure(int): bool`) property slot held a closure that was invoked: 5 > 0. + Assert::assertTrue($ok, 'Handler-typed closure slot was invoked and returned bool'); + + // A single-head alias to a closure-sig alias (`Aliased = Handler`) also erases to `\Closure` and + // loads — proving the transitive case emits a real type, not un-loadable / untyped PHP: -2 < 0. + Assert::assertTrue($aliasOk, 'transitive closure-sig alias slot loaded and was invoked'); + + // A closure signature as a compound member (`MaybeCheck = ?Closure(int): bool`) erases to + // `?\Closure` and loads — proving a closure-in-compound slot is emitted valid: 0 === 0. + Assert::assertTrue($maybeOk, 'nullable closure-sig slot (?\\Closure) loaded and was invoked'); + + // The generic `Mapper` param slot held a closure invoked with an int, returning a string. + Assert::assertSame('n3', $mapped, 'Mapper closure slot was invoked and returned a string'); +}; diff --git a/test/fixture/compile/intersection_alias/source/Shapes.xphp b/test/fixture/compile/intersection_alias/source/Shapes.xphp new file mode 100644 index 0000000..86377ed --- /dev/null +++ b/test/fixture/compile/intersection_alias/source/Shapes.xphp @@ -0,0 +1,42 @@ +both = $both; + $this->deduped = $deduped; + } + + public function dnf(Dnf $x): Dnf + { + return $x; + } +} + +$holder = new Holder(new AB(), new AB()); +$dnfAB = $holder->dnf(new AB()); +$dnfC = $holder->dnf(new ABC()); diff --git a/test/fixture/compile/intersection_alias/verify/runtime.php b/test/fixture/compile/intersection_alias/verify/runtime.php new file mode 100644 index 0000000..affc9de --- /dev/null +++ b/test/fixture/compile/intersection_alias/verify/runtime.php @@ -0,0 +1,32 @@ +targetDir . '/Shapes.php'; + + // The `Both $both` property + constructor param (`A&B`) accepted an object implementing both. + Assert::assertInstanceOf('App\\Inter\\AB', $holder->both, 'intersection slot A&B accepted an A&B object'); + + // The composed `Deduped` slot (`Inner & B` = A&B&B → deduped to A&B) loaded and accepted an A&B + // object — proving the emitted intersection has no duplicate member (which would fatal at load). + Assert::assertInstanceOf('App\\Inter\\AB', $holder->deduped, 'composed intersection deduped to A&B and loaded'); + + // The `Dnf` slot `(A&B)|C` accepted the A&B arm and the C arm, round-tripping each. + Assert::assertInstanceOf('App\\Inter\\AB', $dnfAB, 'DNF (A&B)|C accepted the A&B arm'); + Assert::assertInstanceOf('App\\Inter\\ABC', $dnfC, 'DNF (A&B)|C accepted the C arm'); +};