From ccc5d69c779ccbb2a985a76b109c6c4f2e7213a6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 6 Aug 2026 22:52:09 +0000 Subject: [PATCH 01/15] refactor(monomorphize): represent an alias body as a DNF AliasBody VO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the type-alias body from a flat union list to an AliasBody value object wrapping a DNF list> (union of intersection-clauses): a single head is [[X]], a union [[A],[B]], and ?X desugars to [[X],[null]]. The two-condition single-head predicate (one clause, one leaf) lives on the VO so it stays in one place and carries mutation coverage; every expander consumer — the slot path, the generic-argument path, and the bound path — routes through it. Behavior-preserving: only union / nullable / single-head bodies are read, so every clause is single-leaf and the emitted PHP is unchanged. This is the seam that makes intersection / DNF bodies an easy addition. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/AliasBody.php | 57 +++++++ .../Monomorphize/XphpSourceParser.php | 158 ++++++++++-------- 2 files changed, 144 insertions(+), 71 deletions(-) create mode 100644 src/Transpiler/Monomorphize/AliasBody.php diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php new file mode 100644 index 0000000..ab33f43 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -0,0 +1,57 @@ +> $clauses union of intersection-clauses (DNF); never empty, + * and no inner clause is empty + */ + public function __construct(public array $clauses) + { + } + + /** + * A single (possibly-generic) head — exactly one clause with exactly one leaf. + * Only this shape 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 param / property / return / class-constant slot. + */ + public function isSingleHead(): bool + { + return count($this->clauses) === 1 && count($this->clauses[0]) === 1; + } + + 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 + { + return $this->clauses[0][0]; + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 2111818..4533400 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -329,7 +329,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, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -343,7 +343,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, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2449,7 +2449,7 @@ 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, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2533,16 +2533,16 @@ 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). Today only a flat union of single + * heads and a leading-`?` nullable are read, so every clause is single-leaf; intersection / DNF + * bodies land later. Returns null when the body is a shape not supported (an intersection `&`, a + * parenthesised / DNF form, or a closure signature); `buildAliasTable` then rejects the null body + * with `xphp.alias_unsupported_body`. * * @param list $tokens - * @return list|null */ - private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?array + private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?AliasBody { // Leading `?` → nullable: `?` desugars to ` | null`. A `?` in front of a // compound (`?A|B`) is illegal PHP anyway, so only a single head may follow. @@ -2553,23 +2553,24 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { return null; } - return [$parsed[0], new TypeRef('null')]; + return new AliasBody([[$parsed[0]], [new TypeRef('null')]]); } // 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 = []; + // so the body is declined as unsupported. Each head is its own single-leaf clause — the DNF is + // a pure union until intersection / DNF bodies land. + $clauses = []; $i = $bodyStart; while (true) { $parsed = self::parseTypeArg($tokens, $i); if ($parsed === null) { return null; } - $members[] = $parsed[0]; + $clauses[] = [$parsed[0]]; $next = self::skipWs($tokens, $parsed[1]); if ($next === $semiIdx) { - return $members; + return new AliasBody($clauses); } // @infection-ignore-all NullSafePropertyCall -- `$next <= $semiIdx < count`, so the token // always exists; the `?? null` / `?->` is a defensive floor that never sees null. @@ -2902,8 +2903,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, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:AliasBody}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -3020,7 +3021,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, 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 +3050,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 +3072,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 = []; @@ -3962,10 +3963,13 @@ 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); + // Every clause is single-leaf today, so this stays a leaf (one clause) / any-of + // union (many clauses); an intersection clause becomes a BoundIntersection when + // intersection / DNF bodies land. + return $body->isSingleHead() + ? new BoundLeaf($body->head()) + : new BoundUnion(...array_map(static fn (array $c): BoundLeaf => new BoundLeaf($c[0]), $body->clauses)); } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); @@ -4067,11 +4071,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,34 +4086,38 @@ 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); + return self::dnfToNode($body->clauses, $attrs); } /** - * 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 DNF. Every clause is a single head today, so this + * lowers to a `NullableType` when the sole non-null member is atomic (`?X` ≡ `X|null`) or a + * `UnionType` otherwise; intersection clauses (which lower to an `IntersectionType`, and a + * DNF to a `UnionType` of them) land with intersection / DNF bodies. * - * @param list $members + * @param list> $clauses each clause single-leaf for now * @param array $attrs */ - private static function unionMembersToNode(array $members, array $attrs): Node + private static function dnfToNode(array $clauses, array $attrs): Node { + // Collapse each (single-leaf) clause to its head; a union / nullable is built from the + // heads exactly as before. + $members = array_map(static fn (array $clause): TypeRef => $clause[0], $clauses); $hasNull = false; /** @var list $nonNull */ $nonNull = []; @@ -4150,33 +4158,36 @@ 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. + * + * Today every body clause is single-leaf (a union member), so this is the prior union-flatten + * behavior; multi-leaf (intersection) clause composition lands with intersection / DNF bodies. * * @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 +4195,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 +4210,16 @@ 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; + $clauses = []; + foreach ($this->resolveAliasBody($ref->name, $entry)->clauses as $bodyClause) { + // Each body clause is single-leaf today; substitute its head and expand, flattening + // the resulting clauses in — exactly the prior union-member behavior. + $substituted = self::substituteTypeRef($bodyClause[0], $subst); + foreach ($this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line)->clauses as $c) { + $clauses[] = $c; } } - return $members; + return new AliasBody($clauses); } /** @@ -4224,7 +4237,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 +4270,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 +4318,25 @@ 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 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 $m): TypeRef => $this->resolveTypeRef($m), $clause), + $entry['body']->clauses, + )); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } @@ -4335,9 +4351,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 From dd9e38af3fda0f5048ad3a582671200eb673602a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 6 Aug 2026 23:53:39 +0000 Subject: [PATCH 02/15] feat(monomorphize): accept intersection and DNF type-alias bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type-alias body may now be an intersection (`type Both = A & B;`) or a DNF — a union of intersections (`type Dnf = (A & B) | C;`) — expanded, as any compound body, only as the whole type of a param / property / return / class-constant slot, and as all-of / any-of when named as a bound. The body is read through the shared bound-expression reader and normalized to DNF; a shape that would need distribution (a union nested in an intersection, `(A|B)&C`, whether written directly or reached by expanding a union alias inside an intersection) is rejected with `xphp.alias_compound_needs_distribution` rather than distributed. A scalar or built-in member in an intersection — including one revealed only after a type-parameter substitution (`type Pair = T & Countable; Pair`) — is rejected at emit time with `xphp.alias_scalar_in_intersection`, since PHP forbids it. A nested intersection alias flattens by `&`-associativity, and a cycle through an intersection member is still a clean `xphp.alias_cycle`. Bounds that name an intersection/DNF alias expand to BoundIntersection / BoundUnion, so `class Box` is all-of (an argument implementing only one member is rejected). Single-head / union / nullable bodies are unchanged. Closure-signature bodies remain the one unsupported shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 298 +++++++++++++----- .../Monomorphize/TypeAliasIntegrationTest.php | 125 +++++++- .../intersection_alias/source/Shapes.xphp | 35 ++ .../intersection_alias/verify/runtime.php | 28 ++ 4 files changed, 404 insertions(+), 82 deletions(-) create mode 100644 test/fixture/compile/intersection_alias/source/Shapes.xphp create mode 100644 test/fixture/compile/intersection_alias/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 4533400..c598145 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -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:?AliasBody, 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:?AliasBody, 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 = []; @@ -2449,7 +2451,7 @@ 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:?AliasBody, 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 { @@ -2514,17 +2516,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); return [ [ 'name' => $nameTok->text, 'params' => $params, 'body' => $body, + 'bodyNeedsDistribution' => $bodyNeedsDistribution, 'bytePosition' => $tokens[$typeIdx]->pos, 'line' => $tokens[$typeIdx]->line, ], @@ -2534,51 +2538,115 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? /** * Parse a type-alias body (between `=`, starting at $bodyStart, and its terminator $semiIdx) into - * an {@see AliasBody} (a DNF — union of intersection-clauses). Today only a flat union of single - * heads and a leading-`?` nullable are read, so every clause is single-leaf; intersection / DNF - * bodies land later. Returns null when the body is a shape not supported (an intersection `&`, a - * parenthesised / DNF form, or a closure signature); `buildAliasTable` then rejects the null body - * with `xphp.alias_unsupported_body`. + * 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 array{0: ?AliasBody, 1: bool} */ - private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?AliasBody + private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): 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 single head: `?X` ≡ `X | null`. Only a single atomic head may follow; + // `?(A&B)` is a PHP parse error, so a `?` before anything parseTypeArg can't read 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; + return [null, false]; } - return new AliasBody([[$parsed[0]], [new TypeRef('null')]]); + return [new AliasBody([[$parsed[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. Each head is its own single-leaf clause — the DNF is - // a pure union until intersection / DNF bodies land. - $clauses = []; - $i = $bodyStart; - while (true) { - $parsed = self::parseTypeArg($tokens, $i); - if ($parsed === null) { - return null; - } - $clauses[] = [$parsed[0]]; - $next = self::skipWs($tokens, $parsed[1]); - if ($next === $semiIdx) { - return new AliasBody($clauses); + // Otherwise read a full `|` / `&` type expression (with `(` … `)` groups) via the shared + // bound-expression reader, then normalize to DNF. The reader raises for a `Closure(...)` + // signature (its own feature) — an alias body treats that as an unsupported body rather than + // surfacing a bound-specific error. A body that would need distribution (a union inside an + // intersection) declines with the distribution flag set. + try { + $parsed = self::parseBoundExpr($tokens, $bodyStart); + } catch (XphpParseException) { + return [null, false]; + } + if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + return [null, false]; + } + $dnf = self::boundTreeToDnf($parsed[0]); + if ($dnf === null) { + 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]; + } + + /** + * 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'] === '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']); } /** @@ -2903,7 +2971,7 @@ private static function applyReplacements(string $source, array $replacements): * `xphp.alias_class_collision`. * * @param list $ast - * @param list, body:?AliasBody, bytePosition:int, line:int}> $aliasMarkers + * @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 @@ -2943,9 +3011,18 @@ 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 ' + . 'or generic type, a union, an intersection, a nullable, or a DNF (closure-signature ' . 'bodies are not supported). Use a bare type or a named class.', $marker['line'], self::CODE_ALIAS_UNSUPPORTED_BODY, @@ -3021,7 +3098,7 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:?AliasBody, 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 @@ -3964,12 +4041,17 @@ private function buildBoundExprNode(array $node): BoundExpr // 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. $body = $this->expandAliasToDnf(new TypeRef($fqn, $resolvedArgs), [], 0); - // Every clause is single-leaf today, so this stays a leaf (one clause) / any-of - // union (many clauses); an intersection clause becomes a BoundIntersection when - // intersection / DNF bodies land. - return $body->isSingleHead() - ? new BoundLeaf($body->head()) - : new BoundUnion(...array_map(static fn (array $c): BoundLeaf => new BoundLeaf($c[0]), $body->clauses)); + // 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. + $clauseBounds = array_map( + static fn (array $c): BoundExpr => count($c) === 1 + ? new BoundLeaf($c[0]) + : new BoundIntersection(...array_map(static fn (TypeRef $l): BoundLeaf => new BoundLeaf($l), $c)), + $body->clauses, + ); + return count($clauseBounds) === 1 ? $clauseBounds[0] : new BoundUnion(...$clauseBounds); } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); @@ -4101,50 +4183,91 @@ private function expandAliasName(Name $node): ?Node XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, ); } - return self::dnfToNode($body->clauses, $attrs); + return self::dnfToNode($body->clauses, $attrs, $node->getStartLine()); } /** - * Build the PHP type node for an expanded DNF. Every clause is a single head today, so this - * lowers to a `NullableType` when the sole non-null member is atomic (`?X` ≡ `X|null`) or a - * `UnionType` otherwise; intersection clauses (which lower to an `IntersectionType`, and a - * DNF to a `UnionType` of them) land with intersection / DNF bodies. + * 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> $clauses each clause single-leaf for now + * @param list> $clauses * @param array $attrs */ - private static function dnfToNode(array $clauses, array $attrs): Node + private static function dnfToNode(array $clauses, array $attrs, int $line): Node { - // Collapse each (single-leaf) clause to its head; a union / nullable is built from the - // heads exactly as before. - $members = array_map(static fn (array $clause): TypeRef => $clause[0], $clauses); $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]->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); + if ($hasNull && count($nonNull) === 1 && count($nonNull[0]) === 1) { + // `?X` — the sole non-null member is a single atomic head; emit a NullableType. + /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ + $atomic = Specializer::typeRefToNode($nonNull[0][0], []); + return new Node\NullableType($atomic, $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) { + /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ + $atomic = Specializer::typeRefToNode($clause[0], $attrs); + return $atomic; + } + /** @var list $nodes — each intersection member is a single atomic head */ + $nodes = []; + 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. + if (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, + ); + } + /** @var Node\Identifier|Name $leafNode */ + $leafNode = Specializer::typeRefToNode($leaf, []); + $nodes[] = $leafNode; + } + return new Node\IntersectionType($nodes, $attrs); + } + /** * 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 @@ -4182,8 +4305,9 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef * union becomes a union too, and a union member that is itself a union alias flattens in. A * cycle or arity mismatch throws. * - * Today every body clause is single-leaf (a union member), so this is the prior union-flatten - * behavior; multi-leaf (intersection) clause composition lands with intersection / DNF bodies. + * 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 */ @@ -4212,12 +4336,38 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia } $clauses = []; foreach ($this->resolveAliasBody($ref->name, $entry)->clauses as $bodyClause) { - // Each body clause is single-leaf today; substitute its head and expand, flattening - // the resulting clauses in — exactly the prior union-member behavior. - $substituted = self::substituteTypeRef($bodyClause[0], $subst); - foreach ($this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line)->clauses as $c) { - $clauses[] = $c; + if (count($bodyClause) === 1) { + // A single-leaf clause (a union member) may expand to any DNF — its clauses + // flatten into the union. + $substituted = self::substituteTypeRef($bodyClause[0], $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 leaf that + // is itself an intersection alias contributes its own leaves (`&`-associativity: + // `Inner=A&B` inside `Outer=Inner&C` → `A&B&C`); a leaf that expands to a union is a + // distribution the alias machinery rejects. + $merged = []; + foreach ($bodyClause as $leaf) { + $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 new AliasBody($clauses); } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 5f8ed30..3f25a82 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -54,6 +54,25 @@ 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(); + } + } + public function testGenericAliasExpandsToItsBodySpecialization(): void { $use = self::read($this->compile([ @@ -177,6 +196,76 @@ 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' => " $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 +278,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 +611,34 @@ 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. + // A closure-signature body is recognized (stripped) but rejected with a clear diagnostic — not + // a raw PHP parse error. (Single-head, union, nullable, intersection, and DNF bodies ARE + // supported — see the union / intersection 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, or a DNF ' + . '(closure-signature bodies are not supported). Use a bare type'; + foreach ([ + // A closure signature — recognized and declined (not surfaced as a bound error). + 'closure' => 'type Handler = Closure(int): int;', + // A body the bound-expression reader cannot read (leads with `|`) — declined, not crashed. + 'malformed' => 'type Bad = |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/intersection_alias/source/Shapes.xphp b/test/fixture/compile/intersection_alias/source/Shapes.xphp new file mode 100644 index 0000000..1e7c077 --- /dev/null +++ b/test/fixture/compile/intersection_alias/source/Shapes.xphp @@ -0,0 +1,35 @@ +both = $both; + } + + public function dnf(Dnf $x): Dnf + { + return $x; + } +} + +$holder = new Holder(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..5d48d5d --- /dev/null +++ b/test/fixture/compile/intersection_alias/verify/runtime.php @@ -0,0 +1,28 @@ +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 `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'); +}; From 4f8438ff8317b8e86d55a532e2247008a8e1b89a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 6 Aug 2026 23:53:48 +0000 Subject: [PATCH 03/15] docs(type-aliases): document intersection and DNF bodies Lift the caveats, syntax tour, error catalog, and changelog to reflect that intersection and DNF alias bodies are supported (whole-slot, all-of / any-of as a bound); only closure-signature bodies remain unsupported. Add the xphp.alias_compound_needs_distribution and xphp.alias_scalar_in_intersection codes. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++---------- docs/caveats.md | 43 ++++++++++++++++++++++--------------- docs/errors.md | 6 ++++-- docs/syntax/type-aliases.md | 43 ++++++++++++++++++++++++------------- 4 files changed, 73 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b596807..0b686bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,19 +14,23 @@ 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`), or a **DNF** — a union of intersections (`(A&B)|C`): 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) expands as + the whole type of a parameter, property, return, or class-constant slot — or, as a + **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` — a closure signature), + 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/caveats.md b/docs/caveats.md index b44268a..cd78be3 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -94,22 +94,29 @@ 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`), and a DNF (`(A & B) | C`) body are +all supported; parameters may carry defaults and bounds. The remaining limits are on +the body shape (closure signatures, 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 +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: +// 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 +145,21 @@ 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) 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. Two body shapes stay out: a **closure signature** +(its own feature), 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*. +- For a closure body, write the type directly, or wrap it in a named class or + interface and alias *that*. 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..7768d00 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, or a DNF — today this is a closure signature (`Closure(int): int`) | +| `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/syntax/type-aliases.md b/docs/syntax/type-aliases.md index 5ba346e..c807134 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -65,11 +65,14 @@ 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`), or a **DNF** — a union of intersections (`(A & B) | C`). 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) expands only as + the *whole* type of a parameter, property, return, or class-constant slot + (see caveats) — except as a **bound**, where an intersection is all-of + (`type B`) and a union is any-of. - **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 +101,15 @@ 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 closure-signature body (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 +122,17 @@ 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`. +- **Closure bodies** (`Closure(int): int`) are rejected with + `xphp.alias_unsupported_body` — write the type directly or wrap it in a + named class/interface. +- **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`. +- **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 From 6bd12e14474e12ef89edcc45f995ede47686d267 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 00:09:09 +0000 Subject: [PATCH 04/15] fix(monomorphize): dedupe redundant intersection and union alias members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A duplicate member in an emitted intersection or union — `A&A`, or a composed `type Outer = Inner & B` where `Inner = A & B` emitting `A&B&B`, or `A|A` — is a PHP "Duplicate type is redundant" PARSE fatal that takes down the whole generated file. Dedupe members by canonical name at emit (identity-preserving: `A&A` ≡ `A`, `A&B&B` ≡ `A&B`, `A|A` ≡ `A`), so a composed alias that reintroduces a member collapses cleanly instead of emitting an unparseable file. The scalar-in-intersection guard already covered the analogous scalar fatal; this closes the duplicate case. Found in code review. Subtype-redundancy (`A&B` with `B extends A`) is left as-is — PHP accepts it, only exact-name duplicates fatal. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/type-aliases.md | 4 ++ src/Transpiler/Monomorphize/AliasBody.php | 50 +++++++++++++++++++ .../Monomorphize/XphpSourceParser.php | 23 +++++++-- .../Monomorphize/TypeAliasIntegrationTest.php | 25 ++++++++++ .../intersection_alias/source/Shapes.xphp | 11 +++- .../intersection_alias/verify/runtime.php | 4 ++ 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index c807134..89fd96a 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -129,6 +129,10 @@ for the details and the reasons: 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* diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php index ab33f43..f2500a6 100644 --- a/src/Transpiler/Monomorphize/AliasBody.php +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -54,4 +54,54 @@ public function head(): TypeRef { return $this->clauses[0][0]; } + + /** + * Dedupe the leaves of an intersection clause by canonical name, 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 = $leaf->canonical(); + 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 $leaf): string => $leaf->canonical(), $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 c598145..0ca5953 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -4212,6 +4212,10 @@ private static function dnfToNode(array $clauses, array $attrs, int $line): Node $nonNull[] = $clause; } } + // 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 head; emit a NullableType. /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ @@ -4247,8 +4251,6 @@ private static function clauseToNode(array $clause, array $attrs, int $line): No $atomic = Specializer::typeRefToNode($clause[0], $attrs); return $atomic; } - /** @var list $nodes — each intersection member is a single atomic head */ - $nodes = []; 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 @@ -4261,10 +4263,21 @@ private static function clauseToNode(array $clause, array $attrs, int $line): No XphpSourceParser::CODE_ALIAS_SCALAR_IN_INTERSECTION, ); } - /** @var Node\Identifier|Name $leafNode */ - $leafNode = Specializer::typeRefToNode($leaf, []); - $nodes[] = $leafNode; } + // 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 head. + $unique = AliasBody::dedupeLeaves($clause); + if (count($unique) === 1) { + /** @var Node\Identifier|Name $atomic */ + $atomic = Specializer::typeRefToNode($unique[0], $attrs); + // @infection-ignore-all ReturnRemoval -- falling through builds a one-member + // IntersectionType, which nikic pretty-prints identically to the bare atomic head, + // so removing this early return is output-equivalent; the branch is a clarity guard. + return $atomic; + } + /** @var list $nodes — each intersection member is a single atomic head */ + $nodes = array_map(static fn (TypeRef $leaf): Node => Specializer::typeRefToNode($leaf, []), $unique); return new Node\IntersectionType($nodes, $attrs); } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 3f25a82..4a1ff5d 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -212,6 +212,31 @@ public function testIntersectionAndDnfBodiesExpandInWholeSlots(): void self::assertStringContainsString('public \\App\\A&\\DateTimeInterface $fq', $use); } + public function testRedundantIntersectionAndUnionMembersAreDeduped(): void + { + // A duplicate member in an intersection / union is a PHP "Duplicate type … is redundant" PARSE + // fatal — it would take down the whole emitted file. Duplicates are identity-preserving, so they + // collapse: `A&A` ≡ `A`, `A&B&B` ≡ `A&B` (a composed alias reintroducing a member), `A|A` ≡ `A`. + // The emitted file must load — asserted structurally here and executed in the runtime fixture. + $use = self::read($this->compile([ + 'Use.xphp' => "both = $both; + $this->deduped = $deduped; } public function dnf(Dnf $x): Dnf @@ -30,6 +37,6 @@ class Holder } } -$holder = new Holder(new AB()); +$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 index 5d48d5d..affc9de 100644 --- a/test/fixture/compile/intersection_alias/verify/runtime.php +++ b/test/fixture/compile/intersection_alias/verify/runtime.php @@ -22,6 +22,10 @@ // 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'); From 87c49c29bc18328ee7c1f578d5ebbcba6523cba0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 12:27:48 +0000 Subject: [PATCH 05/15] feat(monomorphize): accept closure-signature type-alias bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type-alias body may now be a closure signature — `type Handler = Closure(int $x): bool;` — and, generic, `type Mapper = Closure(T): R;`. Used as the whole type of a param / property / return slot it erases to a bare `\Closure`, carrying the parsed signature on ATTR_CLOSURE_SIG so the existing conformance validator checks call sites and factory returns against it — identical to a directly-written `Closure(...)` in that slot. A generic closure-sig alias, or one whose signature references an enclosing class type parameter, grounds per specialization through the Specializer's existing ATTR_CLOSURE_SIG handling; compile and check agree. The body is recognized via the ungated closure-signature core (findClosureSigEnd + buildClosureSignature) — the gated scanner requires a following `$var`/return slot, which a `;`-terminated alias body is not — and stored on the AliasBody value object as a signature variant. Used anywhere other than a whole slot (a generic argument, `new`, a bound) it is `xphp.alias_compound_in_non_slot`, never an un-`new`-able `new \Closure()`. A closure signature combined with a union (`A | Closure(...)`) or nullable (`?Closure(...)`) stays unsupported. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/AliasBody.php | 31 ++++-- .../Monomorphize/XphpSourceParser.php | 74 ++++++++++---- .../Monomorphize/TypeAliasIntegrationTest.php | 99 +++++++++++++++++-- .../closure_sig_alias/source/Handlers.xphp | 34 +++++++ .../closure_sig_alias/verify/runtime.php | 26 +++++ 5 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 test/fixture/compile/closure_sig_alias/source/Handlers.xphp create mode 100644 test/fixture/compile/closure_sig_alias/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php index f2500a6..309bb9d 100644 --- a/src/Transpiler/Monomorphize/AliasBody.php +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -20,26 +20,43 @@ * are `TypeRef`s either way. The two-condition single-head predicate lives here * (not inlined at each expander consumer) so it stays in one place and carries * mutation coverage. + * + * A **closure-signature** body (`type Handler = Closure(int): bool;`) is the one + * shape a DNF cannot represent: it sets {@see $signature} (and carries no clauses). + * Like every other compound, it is usable only as the whole type of a slot, where + * it erases to a bare `\Closure` carrying the signature for conformance checking. */ final readonly class AliasBody { /** - * @param list> $clauses union of intersection-clauses (DNF); never empty, - * and no inner clause is empty + * @param list> $clauses union of intersection-clauses (DNF); non-empty (with no + * empty inner clause) UNLESS this is a closure-signature body + * @param ?ClosureSignature $signature set for a closure-signature body; then $clauses is empty + */ + public function __construct( + public array $clauses, + public ?ClosureSignature $signature = null, + ) { + } + + /** + * A closure-signature body — erases to `\Closure` with the signature carried for conformance. + * A DNF (clause) body never sets this. */ - public function __construct(public array $clauses) + public function isClosureSignature(): bool { + return $this->signature !== null; } /** - * A single (possibly-generic) head — exactly one clause with exactly one leaf. - * Only this shape may expand anywhere a plain type name can (a generic argument, - * `new`, `extends`/`implements`, a bound); a compound body is representable only + * A single (possibly-generic) head — exactly one clause with exactly one leaf. A closure-signature + * body is never a single head. 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 param / property / return / class-constant slot. */ public function isSingleHead(): bool { - return count($this->clauses) === 1 && count($this->clauses[0]) === 1; + return $this->signature === null && count($this->clauses) === 1 && count($this->clauses[0]) === 1; } public function isCompound(): bool diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 0ca5953..aaa59ad 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -362,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; @@ -2453,7 +2453,7 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * @param list $tokens * @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 @@ -2521,7 +2521,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? // 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); + [$body, $bodyNeedsDistribution] = self::parseAliasBody($tokens, $bodyStart, $semiIdx, $source); return [ [ @@ -2553,11 +2553,12 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? * @param list $tokens * @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 single head: `?X` ≡ `X | null`. Only a single atomic head may follow; // `?(A&B)` is a PHP parse error, so a `?` before anything parseTypeArg can't read declines to - // unsupported (`(A&B)|null` is the supported spelling for that shape). + // unsupported (`(A&B)|null` is the supported spelling for that shape). A `?Closure(...)` body + // also declines here (a nullable closure signature is out of scope). // @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 === '?') { @@ -2568,11 +2569,27 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI return [new AliasBody([[$parsed[0]], [new TypeRef('null')]]), false]; } + // A closure-signature body: `Closure(params): return`. The gated tryParseClosureSignature needs + // a following `$var` / return slot, which an alias body — ending at `;` — is not; so recognize + // it via the ungated core (findClosureSigEnd + buildClosureSignature) and require the signature + // to span the WHOLE body (end exactly at the terminator). A partial match (trailing tokens, or a + // closure combined with a union) declines as unsupported. + if (ltrim($tokens[$bodyStart]->text, '\\') === 'Closure') { + $openIdx = self::skipWs($tokens, $bodyStart + 1); + if ($openIdx < $semiIdx && ($tokens[$openIdx]->text === '(' || self::isCastToken($tokens[$openIdx]))) { + $spanEnd = self::findClosureSigEnd($tokens, $openIdx); + if ($spanEnd !== null && self::skipWs($tokens, $spanEnd + 1) === $semiIdx) { + return [new AliasBody([], self::buildClosureSignature($tokens, $openIdx, $source, false)), false]; + } + return [null, false]; + } + } + // Otherwise read a full `|` / `&` type expression (with `(` … `)` groups) via the shared // bound-expression reader, then normalize to DNF. The reader raises for a `Closure(...)` - // signature (its own feature) — an alias body treats that as an unsupported body rather than - // surfacing a bound-specific error. A body that would need distribution (a union inside an - // intersection) declines with the distribution flag set. + // signature nested in a compound (`A | Closure(...)`) — an alias body treats that as unsupported + // rather than surfacing a bound-specific error. A body that would need distribution (a union + // inside an intersection) declines with the distribution flag set. try { $parsed = self::parseBoundExpr($tokens, $bodyStart); } catch (XphpParseException) { @@ -3022,8 +3039,8 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff } throw new XphpParseException( "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " - . 'or generic type, a union, an intersection, a nullable, or a DNF (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, ); @@ -4183,6 +4200,14 @@ private function expandAliasName(Name $node): ?Node XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, ); } + if ($body->signature !== null) { + // A closure-signature body erases to a bare `\Closure` carrying the substituted + // signature on ATTR_CLOSURE_SIG — read identically to a directly-written `Closure(...)` + // by the conformance validator, and grounded per specialization by the Specializer. + $closureNode = new Name\FullyQualified('Closure', $attrs); + $closureNode->setAttribute(XphpSourceParser::ATTR_CLOSURE_SIG, $body->signature); + return $closureNode; + } return self::dnfToNode($body->clauses, $attrs, $node->getStartLine()); } @@ -4347,8 +4372,16 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia foreach (array_column($entry['params'], 'name') as $k => $paramName) { $subst[$paramName] = $paddedArgs[$k]; } + $resolved = $this->resolveAliasBody($ref->name, $entry); + if ($resolved->signature !== null) { + // A closure-signature body: substitute the alias's params into the (already + // namespace-resolved) signature and carry it through as a signature AliasBody. Any + // still-abstract enclosing type-parameter leaf stays `isTypeParam` for the + // Specializer to ground per specialization. + return new AliasBody([], Specializer::substituteClosureSignature($resolved->signature, Substitution::of($subst))); + } $clauses = []; - foreach ($this->resolveAliasBody($ref->name, $entry)->clauses as $bodyClause) { + foreach ($resolved->clauses as $bodyClause) { if (count($bodyClause) === 1) { // A single-leaf clause (a union member) may expand to any DNF — its clauses // flatten into the union. @@ -4490,16 +4523,19 @@ private function resolveAliasBody(string $fqn, array $entry): AliasBody if (isset($this->aliasBodyCache[$fqn])) { return $this->aliasBodyCache[$fqn]; } - // Resolve every leaf of every clause 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 = new AliasBody(array_map( - fn (array $clause): array => array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $clause), - $entry['body']->clauses, - )); + $body = $entry['body']; + $resolved = $body->signature !== null + ? new AliasBody([], $this->resolveClosureSignature($body->signature)) + : new AliasBody(array_map( + fn (array $clause): array => array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $clause), + $body->clauses, + )); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 4a1ff5d..2f92e93 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -73,6 +73,25 @@ public function testIntersectionAndDnfBodiesExpandAndRunAtRuntime(): void } } + #[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([ @@ -237,6 +256,68 @@ public function testRedundantIntersectionAndUnionMembersAreDeduped(): void self::assertStringNotContainsString(')|(', $use); } + public function testClosureSignatureBodyErasesToClosureInWholeSlots(): void + { + // A closure-signature body erases to a bare `\Closure` in a param / property / return slot; a + // generic closure-sig alias erases the same way, with its signature substituted per use. + $use = self::read($this->compile([ + 'Use.xphp' => " = Closure(T): R;\nclass Svc {\n public Handler \$h;\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); + // 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 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)), @@ -636,17 +717,19 @@ public function testASelfReferentialGenericAliasBoundIsRejectedAsACycleNotACrash public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { - // A closure-signature body is recognized (stripped) but rejected with a clear diagnostic — not - // a raw PHP parse error. (Single-head, union, nullable, intersection, and DNF bodies ARE - // supported — see the union / intersection 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, or a DNF ' - . '(closure-signature bodies are not supported). Use a bare type'; + // 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 closure signature — recognized and declined (not surfaced as a bound error). - 'closure' => 'type Handler = Closure(int): int;', // A body the bound-expression reader cannot read (leads with `|`) — declined, not crashed. 'malformed' => 'type Bad = |A;', + // A closure signature combined with a union — a mixed body is out of scope. + 'closure-union' => 'type Bad = A | Closure(int): int;', + // A nullable closure signature — out of scope (declined by the leading-`?` reader). + 'nullable-closure' => 'type Bad = ?Closure(int): int;', ] as $body) { $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); diff --git a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp new file mode 100644 index 0000000..e47026f --- /dev/null +++ b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp @@ -0,0 +1,34 @@ + = Closure(T): R; + +class Registry +{ + public Handler $check; + + public function __construct(Handler $check) + { + $this->check = $check; + } + + public function run(int $n): bool + { + return ($this->check)($n); + } + + public function map(Mapper $f, int $n): string + { + return $f($n); + } +} + +$reg = new Registry(fn(int $x): bool => $x > 0); +$ok = $reg->run(5); +$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..0132c93 --- /dev/null +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -0,0 +1,26 @@ + = 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 . '/Handlers.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'); + + // 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'); +}; From bc0a703040f872d712ccc2f49c1a8634c7d71124 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 12:29:45 +0000 Subject: [PATCH 06/15] docs(type-aliases): document closure-signature bodies Lift the caveats, syntax tour, error catalog, and changelog: a closure signature is now a supported alias body (erased to `\Closure`, conformance- checked, grounded per specialization for a generic one). Only a closure combined with a union/nullable (`A | Closure(...)`, `?Closure(...)`) stays unsupported. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++++++------ docs/caveats.md | 37 ++++++++++++++++++++----------------- docs/errors.md | 2 +- docs/syntax/type-aliases.md | 31 +++++++++++++++++++------------ 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b686bf..03caed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`), a **nullable** (`?Box`), an - **intersection** (`A&B`), or a **DNF** — a union of intersections (`(A&B)|C`): + **intersection** (`A&B`), a **DNF** — a union of intersections (`(A&B)|C`) — or a + **closure signature** (`Closure(int): bool`, generic `Mapper = Closure(T): R`, + 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 compound (union / nullable / intersection / DNF) expands as - the whole type of a parameter, property, return, or class-constant slot — or, as a - **bound**, all-of for an intersection and any-of for a union. Aliases compose + `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 @@ -27,8 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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` — a closure signature), - compound-in-non-slot (`xphp.alias_compound_in_non_slot`), distribution-requiring + unsupported-body (`xphp.alias_unsupported_body` — e.g. a closure signature mixed + into a union/nullable), 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 diff --git a/docs/caveats.md b/docs/caveats.md index cd78be3..87a309d 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -94,15 +94,17 @@ 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`), -a nullable (`?Box`), an intersection (`A & B`), and a DNF (`(A & B) | C`) body are -all supported; parameters may carry defaults and bounds. The remaining limits are on -the body shape (closure signatures, distribution) and the positions a compound alias -can take. +a nullable (`?Box`), an intersection (`A & B`), a DNF (`(A & B) | C`), and a closure +signature (`Closure(int): bool`) body are all supported; parameters may carry defaults +and bounds. The remaining limits are on the body shape (a closure combined with a +union/nullable, distribution) and the positions a compound alias can take. ### ❌ What doesn't work ```php -type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature +type Fn = Closure(int): int; // ✓ closure signature — erases to \Closure in a whole slot +type Bad = A | Closure(int): int;// ✗ xphp.alias_unsupported_body — closure combined with a union +type Nul = ?Closure(int): int; // ✗ xphp.alias_unsupported_body — nullable closure signature // No distribution: a union nested inside an intersection: type Bad = (A | B) & C; // ✗ xphp.alias_compound_needs_distribution @@ -145,21 +147,22 @@ keep one namespace per file, or fully-qualify. ### Why -A compound body (union / intersection / nullable / DNF) 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. Two body shapes stay out: a **closure signature** -(its own feature), 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. +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: a **closure signature +mixed into a union/nullable** (a plain `Closure(...)` body works; `A | Closure(...)` +does not — the signature rides a bare `\Closure` only), 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 a closure body, write the type directly, or wrap it in a named class or - interface and alias *that*. For a `(A|B)&C` body, write the DNF `(A&C)|(B&C)`. +- For a closure body mixed with a union/nullable, use a bare `\Closure` in the + union, or write the type directly. 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 7768d00..5155b55 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -60,7 +60,7 @@ 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 union, an intersection, a nullable, or a DNF — today this is a closure signature (`Closure(int): int`) | +| `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 — e.g. a closure signature combined with a union (`A \| Closure(int): int`) or nullable (`?Closure(int): int`) | | `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 | diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index 89fd96a..47fc3aa 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -66,13 +66,18 @@ no separate code path and no runtime cost. separator is `=`. - **Bodies**: a single (possibly-generic) head (`Ident`, `Dict`), a **union** (`int|string`), a **nullable** (`?Box`), an **intersection** - (`A & B`), or a **DNF** — a union of intersections (`(A & B) | C`). 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) expands only as - the *whole* type of a parameter, property, return, or class-constant slot - (see caveats) — except as a **bound**, where an intersection is all-of - (`type B`) and a union is any-of. + (`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 @@ -101,8 +106,9 @@ 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` — a closure-signature body (see caveats - below). + - `xphp.alias_unsupported_body` — a body that is none of the supported + shapes, e.g. a closure signature combined with a union (`A | Closure(...)`) + or nullable (`?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 @@ -122,9 +128,10 @@ 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.) -- **Closure bodies** (`Closure(int): int`) are rejected with - `xphp.alias_unsupported_body` — write the type directly or wrap it in a - named class/interface. +- **A closure signature combined with a union or nullable** + (`A | Closure(int): int`, `?Closure(int): int`) is + `xphp.alias_unsupported_body` — a plain `Closure(...)` body works; a mixed + one does not. (A bare `\Closure` may be combined freely.) - **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 From 1022cec38397eb4f30093a5c3044bf85ec223713 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 12:45:12 +0000 Subject: [PATCH 07/15] test(monomorphize): cover closure-sig alias parse edge cases Pin the closure-signature body recognition: a fully-qualified `\Closure(...)` body, a bare `\Closure` combined in a union (which is NOT a signature body and stays a union), and a complete signature followed by a trailing token (which must decline rather than silently drop it). Justify the two equivalent mutants in the recognizer (the `<`/`<=` boundary that only differs on a bare `type X = Closure;`, and the unobservable distribution flag on a valid body). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/XphpSourceParser.php | 6 ++++++ .../Monomorphize/TypeAliasIntegrationTest.php | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index aaa59ad..ebd3b7f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2576,9 +2576,15 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI // closure combined with a union) declines as unsupported. if (ltrim($tokens[$bodyStart]->text, '\\') === 'Closure') { $openIdx = self::skipWs($tokens, $bodyStart + 1); + // @infection-ignore-all LessThan -- `<` vs `<=` differs only when $openIdx === $semiIdx + // (a bare `type X = Closure;`), where $tokens[$openIdx] is the `;` — never `(` / a cast — so + // the inner guard is false either way and the branch is skipped identically. if ($openIdx < $semiIdx && ($tokens[$openIdx]->text === '(' || self::isCastToken($tokens[$openIdx]))) { $spanEnd = self::findClosureSigEnd($tokens, $openIdx); if ($spanEnd !== null && self::skipWs($tokens, $spanEnd + 1) === $semiIdx) { + // @infection-ignore-all FalseValue -- the distribution flag rides a NON-null body + // here; buildAliasTable reads the flag only when the body is null, so its value on a + // valid closure body is unobservable. return [new AliasBody([], self::buildClosureSignature($tokens, $openIdx, $source, false)), false]; } return [null, false]; diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 2f92e93..d42b20b 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -261,12 +261,17 @@ public function testClosureSignatureBodyErasesToClosureInWholeSlots(): void // A closure-signature body erases to a bare `\Closure` in a param / property / return slot; a // generic closure-sig alias erases the same way, with its signature substituted per use. $use = self::read($this->compile([ - 'Use.xphp' => " = Closure(T): R;\nclass Svc {\n public Handler \$h;\n public function run(Handler \$c): bool { return \$c(1); }\n public function map(Mapper \$f): string { return \$f(1); }\n}\n", + '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); @@ -726,8 +731,13 @@ public function testUnsupportedAliasBodyIsRejectedInBothModes(): void foreach ([ // A body the bound-expression reader cannot read (leads with `|`) — declined, not crashed. 'malformed' => 'type Bad = |A;', - // A closure signature combined with a union — a mixed body is out of scope. - 'closure-union' => 'type Bad = A | Closure(int): int;', + // A closure signature combined with a union (`A | Closure(...)`) is out of scope — the + // bound-expression reader declines it. (A union RETURN type, `Closure(int): int | A`, is a + // valid closure signature and is accepted.) + 'closure-after-union' => 'type Bad = A | Closure(int): int;', + // 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;', // A nullable closure signature — out of scope (declined by the leading-`?` reader). 'nullable-closure' => 'type Bad = ?Closure(int): int;', ] as $body) { From 26efe7b326010e65dbc8178df98e0cb25a54fba9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 13:05:04 +0000 Subject: [PATCH 08/15] fix(monomorphize): handle a closure-sig alias reached transitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closure-signature alias reached through another alias's clause leaf (`type A = Closure(int): bool; type B = A;`) was silently dropped: the clause loop in expandAliasToDnf read only `->clauses`, which is empty for a signature body, so the `\Closure` type and its conformance check vanished — emitting un-loadable / untyped PHP (a whole-slot `type B = A` wrote `function make(): {`), accepting a non-conforming closure, and dropping a union arm. Now a signature reached as the SOLE single-head clause propagates (a single-head alias to a closure-sig alias IS that signature — it erases to `\Closure` and its conformance rides through); a signature combined in a union or intersection is `xphp.alias_unsupported_body`; and a closure-sig alias used as a bound is `xphp.alias_compound_in_non_slot`. Found in code review. The runtime fixture now executes a transitive alias slot, proving the emitted PHP loads. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 39 ++++++++++++++++++- .../Monomorphize/TypeAliasIntegrationTest.php | 38 ++++++++++++++++++ .../closure_sig_alias/source/Handlers.xphp | 15 ++++++- .../closure_sig_alias/verify/runtime.php | 4 ++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index ebd3b7f..79fa4a5 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -4064,6 +4064,19 @@ private function buildBoundExprNode(array $node): BoundExpr // 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. $body = $this->expandAliasToDnf(new TypeRef($fqn, $resolvedArgs), [], 0); + if ($body->signature !== null) { + // A closure-signature alias has no bound representation (a bound is a + // subtype constraint over named types); it is usable only as a whole slot. + // @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 @@ -4392,7 +4405,23 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia // A single-leaf clause (a union member) may expand to any DNF — its clauses // flatten into the union. $substituted = self::substituteTypeRef($bodyClause[0], $subst); - foreach ($this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line)->clauses as $c) { + $expanded = $this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line); + if ($expanded->signature !== null) { + // The leaf resolves to a closure signature. That is representable only when + // the WHOLE body is this one leaf (`type B = A` where A is a closure-sig alias + // — B is then that same signature). Combined with any other clause (a union) + // a closure signature has no representation. + if (count($resolved->clauses) === 1) { + return $expanded; + } + throw new XphpParseException( + "Type alias `{$ref->name}` has an unsupported body: a closure signature " + . 'cannot be combined with another type in a union or intersection.', + $line, + XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } + foreach ($expanded->clauses as $c) { $clauses[] = $c; } continue; @@ -4406,6 +4435,14 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia foreach ($bodyClause as $leaf) { $substituted = self::substituteTypeRef($leaf, $subst); $expanded = $this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line); + if ($expanded->signature !== null) { + throw new XphpParseException( + "Type alias `{$ref->name}` has an unsupported body: a closure signature " + . 'cannot be combined with another type in a union or intersection.', + $line, + XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } if (count($expanded->clauses) > 1) { throw new XphpParseException( "Type alias `{$ref->name}` has a body that would require distribution: a " diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index d42b20b..ab0124b 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -307,6 +307,44 @@ public function testGenericClosureSignatureAliasGroundsPerSpecialization(): void 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 in a union or an intersection has no representation — + // unsupported. The full message is asserted so a reworded / reordered diagnostic is caught. + $combinedMessage = 'has an unsupported body: a closure signature cannot be combined with ' + . 'another type in a union or intersection.'; + foreach ([ + 'union' => 'type U = A | Foo;', + 'intersection' => 'type U = A & Foo;', + ] as $decl) { + $combined = ['C.xphp' => $header . "{$decl}\nclass Svc { public U \$h; }\n"]; + self::assertRejected($this->check($combined), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $combinedMessage); + $this->assertCompileThrows($combined, $combinedMessage); + } + + // A closure-sig alias 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 testClosureSignatureAliasInNonSlotIsRejectedInBothModes(): void { // A closure-signature alias is a whole-slot type only — as a generic argument or in `new` it is diff --git a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp index e47026f..79e509a 100644 --- a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp +++ b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp @@ -8,14 +8,19 @@ namespace App\Clo; // alias for conformance checking. A generic alias grounds its signature per use. type Handler = Closure(int $x): bool; type Mapper = 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; class Registry { public Handler $check; + public Aliased $alias; - public function __construct(Handler $check) + public function __construct(Handler $check, Aliased $alias) { $this->check = $check; + $this->alias = $alias; } public function run(int $n): bool @@ -23,12 +28,18 @@ class Registry 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); +$reg = new Registry(fn(int $x): bool => $x > 0, fn(int $x): bool => $x < 0); $ok = $reg->run(5); +$aliasOk = $reg->viaAlias(-2); $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 index 0132c93..45789f6 100644 --- a/test/fixture/compile/closure_sig_alias/verify/runtime.php +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -21,6 +21,10 @@ // 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'); + // 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'); }; From 35a340c775b99134e1166513006bfeb73d77e9db Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 14:08:40 +0000 Subject: [PATCH 09/15] docs(type-aliases): sync roadmap, comparison, index, and ADR with the full body set The roadmap Shipped section + timeline, the syntax index row, the comparison grid + prose, and ADR-0023's scope note still described type-alias bodies as single-head / union / nullable only (and listed intersection / DNF / closure as unsupported / roadmapped). Update them to the shipped set: intersection, DNF, and closure-signature bodies, with the new distribution / scalar codes; the ADR keeps its historical decision and gains a forward-note that the richer bodies landed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr/0023-type-alias-declaration-syntax.md | 11 +++++---- docs/guides/comparison.md | 8 ++++--- docs/roadmap.md | 23 +++++++++++++------ docs/syntax/index.md | 2 +- 4 files changed, 28 insertions(+), 16 deletions(-) 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/guides/comparison.md b/docs/guides/comparison.md index de325cb..5c544c2 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, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and a closure signature combined with a union/nullable isn't supported) | ❌ | ✅ | ✅ | ✅ | | 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..2199f15 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,17 +238,25 @@ 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`) bodies. A single head expands in every type position (incl. + as a generic argument, `Bag`); a compound (union / nullable / + intersection / DNF) or a 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. - 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 +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body (a + closure signature mixed into a union/nullable), 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 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 From e6701e26074af7d620e80b5120a5455b4ca78a50 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 18:19:42 +0000 Subject: [PATCH 10/15] feat(monomorphize): allow a closure signature as a compound alias-body member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closure signature may now be a MEMBER of a compound alias body — nullable (`type N = ?Closure(int): bool;`), a union (`type U = Foo | Closure(...);`), or an intersection (`type X = Foo & Closure(...);`) — erasing to a bare `\Closure` inside the `?`/`|`/`&` node, byte-identical to the directly-written slot types. This gives alias bodies parity with direct `Closure(...)` slots. The AliasBody DNF leaf widens from `TypeRef` to `TypeRef|ClosureSignature` (the separate whole-body `signature` field is gone — a signature is now just a leaf). The alias-body reader threads an `allowClosureSig` flag through the shared bound-expression reader (a defaulted flag, so real generic bounds — where `Closure(...)` stays rejected — are untouched); the `?`-reader handles `?Closure(...)`. Resolve / substitute / expand / dedupe / emit all branch on the leaf kind. Conformance parity, not more: the validator unwraps a NullableType (so `?Closure` is enforced) but does not descend into a union/intersection (so a member is gradual) — exactly matching the direct forms. Because every closure erases to the same `\Closure`, a body with two `\Closure`-erasing leaves would emit a `\Closure|\Closure` PHP duplicate-type fatal and is rejected. A scalar next to a closure in an intersection still rejects on the scalar; a closure-in- compound alias as a bound / generic-argument / `new` stays compound-in-non-slot. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/AliasBody.php | 105 +++--- .../Monomorphize/XphpSourceParser.php | 302 ++++++++++-------- .../Monomorphize/TypeAliasIntegrationTest.php | 68 ++-- .../closure_sig_alias/source/Handlers.xphp | 14 +- .../closure_sig_alias/verify/runtime.php | 4 + 5 files changed, 309 insertions(+), 184 deletions(-) diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php index 309bb9d..d539ad4 100644 --- a/src/Transpiler/Monomorphize/AliasBody.php +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -16,47 +16,39 @@ * `(A|B)&C → (A&C)|(B&C)` is rejected loudly instead * (`xphp.alias_compound_needs_distribution`). * - * The same shape carries a raw (post-parse) body and a resolved one; the leaves - * are `TypeRef`s either way. The two-condition single-head predicate lives here - * (not inlined at each expander consumer) so it stays in one place and carries - * mutation coverage. + * A leaf is a `TypeRef` OR a `ClosureSignature`. A signature leaf erases to a bare + * `\Closure` (carrying the signature on `ATTR_CLOSURE_SIG` for conformance), so it + * can appear anywhere a type can — as the whole body (`Closure(int): bool`), a + * nullable clause (`?Closure(...)` ≡ `[[sig], [null]]`), or a member of a union / + * intersection (`Foo | Closure(...)`). Because every signature erases to the SAME + * `\Closure`, at most one `\Closure`-erasing leaf may appear in a body (two would + * emit a `\Closure|\Closure` / `\Closure&\Closure` PHP duplicate-type fatal). * - * A **closure-signature** body (`type Handler = Closure(int): bool;`) is the one - * shape a DNF cannot represent: it sets {@see $signature} (and carries no clauses). - * Like every other compound, it is usable only as the whole type of a slot, where - * it erases to a bare `\Closure` carrying the signature for conformance checking. + * The single-head / dedup predicates live here (not inlined at each expander + * consumer) so they stay in one place and carry mutation coverage. */ final readonly class AliasBody { /** - * @param list> $clauses union of intersection-clauses (DNF); non-empty (with no - * empty inner clause) UNLESS this is a closure-signature body - * @param ?ClosureSignature $signature set for a closure-signature body; then $clauses is empty + * @param list> $clauses union of intersection-clauses (DNF); + * non-empty, with no empty inner clause */ public function __construct( public array $clauses, - public ?ClosureSignature $signature = null, ) { } /** - * A closure-signature body — erases to `\Closure` with the signature carried for conformance. - * A DNF (clause) body never sets this. - */ - public function isClosureSignature(): bool - { - return $this->signature !== null; - } - - /** - * A single (possibly-generic) head — exactly one clause with exactly one leaf. A closure-signature - * body is never a single head. 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 param / property / return / class-constant slot. + * 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 $this->signature === null && count($this->clauses) === 1 && count($this->clauses[0]) === 1; + return count($this->clauses) === 1 + && count($this->clauses[0]) === 1 + && $this->clauses[0][0] instanceof TypeRef; } public function isCompound(): bool @@ -69,24 +61,65 @@ public function isCompound(): bool */ public function head(): TypeRef { - return $this->clauses[0][0]; + $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). + */ + public static function isClosureErasing(TypeRef|ClosureSignature $leaf): bool + { + 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. + */ + private static function leafKey(TypeRef|ClosureSignature $leaf): string + { + return $leaf instanceof ClosureSignature ? '\\Closure' : $leaf->canonical(); } /** - * Dedupe the leaves of an intersection clause by canonical name, 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 + * 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 + * @param list $clause + * @return list */ public static function dedupeLeaves(array $clause): array { $seen = []; $unique = []; foreach ($clause as $leaf) { - $key = $leaf->canonical(); + $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. @@ -101,15 +134,15 @@ public static function dedupeLeaves(array $clause): array * 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> + * @param list> $clauses + * @return list> */ public static function dedupeClauses(array $clauses): array { $seen = []; $unique = []; foreach ($clauses as $clause) { - $keys = array_map(static fn (TypeRef $leaf): string => $leaf->canonical(), $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])) { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 79fa4a5..9d70285 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 { @@ -2194,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)) { @@ -2220,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 = false, ?string $source = null): ?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 = false, ?string $source = null): ?array { - $first = self::parseAndBound($tokens, $idx); + $first = self::parseAndBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { return null; } @@ -2243,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; } @@ -2261,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 = false, ?string $source = null): ?array { - $first = self::parsePrimaryBound($tokens, $idx); + $first = self::parsePrimaryBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { return null; } @@ -2275,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; } @@ -2293,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 = false, ?string $source = null): ?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; } @@ -2311,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); } /** @@ -2322,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 = false, ?string $source = null): ?array { $n = count($tokens); if ($idx >= $n || !self::isNameToken($tokens[$idx])) { @@ -2339,11 +2344,23 @@ 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; + } + 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` @@ -2555,49 +2572,32 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx, st */ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx, string $source): array { - // Leading `?` → nullable single head: `?X` ≡ `X | null`. Only a single atomic head may follow; - // `?(A&B)` is a PHP parse error, so a `?` before anything parseTypeArg can't read declines to - // unsupported (`(A&B)|null` is the supported spelling for that shape). A `?Closure(...)` body - // also declines here (a nullable closure signature is out of scope). + // 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)); + $after = self::skipWs($tokens, $bodyStart + 1); + $sig = self::tryWholeBodyClosureSig($tokens, $after, $semiIdx, $source); + if ($sig !== null) { + return [new AliasBody([[$sig], [new TypeRef('null')]]), false]; + } + $parsed = self::parseTypeArg($tokens, $after); if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { return [null, false]; } return [new AliasBody([[$parsed[0]], [new TypeRef('null')]]), false]; } - // A closure-signature body: `Closure(params): return`. The gated tryParseClosureSignature needs - // a following `$var` / return slot, which an alias body — ending at `;` — is not; so recognize - // it via the ungated core (findClosureSigEnd + buildClosureSignature) and require the signature - // to span the WHOLE body (end exactly at the terminator). A partial match (trailing tokens, or a - // closure combined with a union) declines as unsupported. - if (ltrim($tokens[$bodyStart]->text, '\\') === 'Closure') { - $openIdx = self::skipWs($tokens, $bodyStart + 1); - // @infection-ignore-all LessThan -- `<` vs `<=` differs only when $openIdx === $semiIdx - // (a bare `type X = Closure;`), where $tokens[$openIdx] is the `;` — never `(` / a cast — so - // the inner guard is false either way and the branch is skipped identically. - if ($openIdx < $semiIdx && ($tokens[$openIdx]->text === '(' || self::isCastToken($tokens[$openIdx]))) { - $spanEnd = self::findClosureSigEnd($tokens, $openIdx); - if ($spanEnd !== null && self::skipWs($tokens, $spanEnd + 1) === $semiIdx) { - // @infection-ignore-all FalseValue -- the distribution flag rides a NON-null body - // here; buildAliasTable reads the flag only when the body is null, so its value on a - // valid closure body is unobservable. - return [new AliasBody([], self::buildClosureSignature($tokens, $openIdx, $source, false)), false]; - } - return [null, false]; - } - } - // Otherwise read a full `|` / `&` type expression (with `(` … `)` groups) via the shared - // bound-expression reader, then normalize to DNF. The reader raises for a `Closure(...)` - // signature nested in a compound (`A | Closure(...)`) — an alias body treats that as unsupported - // rather than surfacing a bound-specific error. A body that would need distribution (a union - // inside an intersection) declines with the distribution flag set. + // bound-expression reader — allowing a `Closure(...)` signature leaf, which erases to `\Closure` + // (so `Closure(...)`, `Foo | Closure(...)`, `Foo & Closure(...)` all read). A body that would + // need distribution (a union inside an intersection) declines with the distribution flag set. + // Any other reader throw (a malformed closure in a real-bound context can't reach here) declines. try { - $parsed = self::parseBoundExpr($tokens, $bodyStart); + $parsed = self::parseBoundExpr($tokens, $bodyStart, allowClosureSig: true, source: $source); } catch (XphpParseException) { return [null, false]; } @@ -2614,6 +2614,30 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI return [new AliasBody($dnf), false]; } + /** + * If a `Closure(params): return` signature begins at $idx and spans exactly to the terminator + * $semiIdx (a whole-body closure), return the parsed {@see ClosureSignature}; otherwise null. Uses + * the ungated core (the gated `tryParseClosureSignature` needs a following `$var`/return slot, which + * a `;`-terminated alias body is not). + * + * @param list $tokens + */ + private static function tryWholeBodyClosureSig(array $tokens, int $idx, int $semiIdx, string $source): ?ClosureSignature + { + if (($tokens[$idx] ?? null) === null || ltrim($tokens[$idx]->text, '\\') !== 'Closure') { + return null; + } + $openIdx = self::skipWs($tokens, $idx + 1); + if ($openIdx >= $semiIdx || !($tokens[$openIdx]->text === '(' || self::isCastToken($tokens[$openIdx]))) { + return null; + } + $spanEnd = self::findClosureSigEnd($tokens, $openIdx); + if ($spanEnd === null || self::skipWs($tokens, $spanEnd + 1) !== $semiIdx) { + return null; + } + return self::buildClosureSignature($tokens, $openIdx, $source, false); + } + /** * Normalize a bound-expression tree (union / intersection / leaf, as produced by * {@see parseBoundExpr}) to DNF clauses — a union of intersection-clauses. A `|` concatenates its @@ -2623,10 +2647,13 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI * distribute. * * @param BoundDict $tree - * @return list>|null null when the tree needs distribution + * @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)]]; } @@ -4023,11 +4050,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 @@ -4064,9 +4107,11 @@ private function buildBoundExprNode(array $node): BoundExpr // 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. $body = $this->expandAliasToDnf(new TypeRef($fqn, $resolvedArgs), [], 0); - if ($body->signature !== null) { - // A closure-signature alias has no bound representation (a bound is a - // subtype constraint over named types); it is usable only as a whole slot. + 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. @@ -4080,11 +4125,12 @@ private function buildBoundExprNode(array $node): BoundExpr // 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. + // 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($c[0]) - : new BoundIntersection(...array_map(static fn (TypeRef $l): BoundLeaf => new BoundLeaf($l), $c)), + ? 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); @@ -4219,13 +4265,16 @@ private function expandAliasName(Name $node): ?Node XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, ); } - if ($body->signature !== null) { - // A closure-signature body erases to a bare `\Closure` carrying the substituted - // signature on ATTR_CLOSURE_SIG — read identically to a directly-written `Closure(...)` - // by the conformance validator, and grounded per specialization by the Specializer. - $closureNode = new Name\FullyQualified('Closure', $attrs); - $closureNode->setAttribute(XphpSourceParser::ATTR_CLOSURE_SIG, $body->signature); - return $closureNode; + 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()); } @@ -4238,19 +4287,19 @@ private function expandAliasName(Name $node): ?Node * whole slot) emits the `IntersectionType` directly; a DNF emits a `UnionType` of clause * nodes (PHP's union-of-intersections shape). * - * @param list> $clauses + * @param list> $clauses * @param array $attrs */ private static function dnfToNode(array $clauses, array $attrs, int $line): Node { $hasNull = false; - /** @var list> $nonNull the non-null clauses */ + /** @var list> $nonNull the non-null clauses */ $nonNull = []; 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 (count($clause) === 1 && !$clause[0]->isGeneric() && strtolower($clause[0]->name) === 'null') { + if (count($clause) === 1 && $clause[0] instanceof TypeRef && !$clause[0]->isGeneric() && strtolower($clause[0]->name) === 'null') { $hasNull = true; } else { $nonNull[] = $clause; @@ -4261,10 +4310,9 @@ private static function dnfToNode(array $clauses, array $attrs, int $line): Node // clauseToNode. $nonNull = AliasBody::dedupeClauses($nonNull); if ($hasNull && count($nonNull) === 1 && count($nonNull[0]) === 1) { - // `?X` — the sole non-null member is a single atomic head; emit a NullableType. - /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ - $atomic = Specializer::typeRefToNode($nonNull[0][0], []); - return new Node\NullableType($atomic, $attrs); + // `?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, @@ -4285,21 +4333,19 @@ private static function dnfToNode(array $clauses, array $attrs, int $line): Node * 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 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) { - /** @var Node\Identifier|Name $atomic — a single non-generic head lowers to an atomic node */ - $atomic = Specializer::typeRefToNode($clause[0], $attrs); - return $atomic; + 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. - if (in_array(strtolower($leaf->name), XphpSourceParser::SCALAR_TYPES, true)) { + // 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.', @@ -4310,21 +4356,39 @@ private static function clauseToNode(array $clause, array $attrs, int $line): No } // 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 head. + // `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) { - /** @var Node\Identifier|Name $atomic */ - $atomic = Specializer::typeRefToNode($unique[0], $attrs); // @infection-ignore-all ReturnRemoval -- falling through builds a one-member - // IntersectionType, which nikic pretty-prints identically to the bare atomic head, + // 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 $atomic; + return self::leafToNode($unique[0], $attrs); } - /** @var list $nodes — each intersection member is a single atomic head */ - $nodes = array_map(static fn (TypeRef $leaf): Node => Specializer::typeRefToNode($leaf, []), $unique); + $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 @@ -4391,58 +4455,40 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia foreach (array_column($entry['params'], 'name') as $k => $paramName) { $subst[$paramName] = $paddedArgs[$k]; } - $resolved = $this->resolveAliasBody($ref->name, $entry); - if ($resolved->signature !== null) { - // A closure-signature body: substitute the alias's params into the (already - // namespace-resolved) signature and carry it through as a signature AliasBody. Any - // still-abstract enclosing type-parameter leaf stays `isTypeParam` for the - // Specializer to ground per specialization. - return new AliasBody([], Specializer::substituteClosureSignature($resolved->signature, Substitution::of($subst))); - } + $aliasSubst = Substitution::of($subst); $clauses = []; - foreach ($resolved->clauses as $bodyClause) { + foreach ($this->resolveAliasBody($ref->name, $entry)->clauses as $bodyClause) { if (count($bodyClause) === 1) { - // A single-leaf clause (a union member) may expand to any DNF — its clauses - // flatten into the union. - $substituted = self::substituteTypeRef($bodyClause[0], $subst); - $expanded = $this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line); - if ($expanded->signature !== null) { - // The leaf resolves to a closure signature. That is representable only when - // the WHOLE body is this one leaf (`type B = A` where A is a closure-sig alias - // — B is then that same signature). Combined with any other clause (a union) - // a closure signature has no representation. - if (count($resolved->clauses) === 1) { - return $expanded; - } - throw new XphpParseException( - "Type alias `{$ref->name}` has an unsupported body: a closure signature " - . 'cannot be combined with another type in a union or intersection.', - $line, - XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, - ); + $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; } - foreach ($expanded->clauses as $c) { + // 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 leaf that - // is itself an intersection alias contributes its own leaves (`&`-associativity: - // `Inner=A&B` inside `Outer=Inner&C` → `A&B&C`); a leaf that expands to a union is a - // distribution the alias machinery rejects. + // 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); + continue; + } $substituted = self::substituteTypeRef($leaf, $subst); $expanded = $this->expandAliasToDnf($substituted, [...$visited, $ref->name], $line); - if ($expanded->signature !== null) { - throw new XphpParseException( - "Type alias `{$ref->name}` has an unsupported body: a closure signature " - . 'cannot be combined with another type in a union or intersection.', - $line, - XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, - ); - } if (count($expanded->clauses) > 1) { throw new XphpParseException( "Type alias `{$ref->name}` has a body that would require distribution: a " @@ -4572,13 +4618,15 @@ private function resolveAliasBody(string $fqn, array $entry): AliasBody // restore is exact and unconditional. $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); - $body = $entry['body']; - $resolved = $body->signature !== null - ? new AliasBody([], $this->resolveClosureSignature($body->signature)) - : new AliasBody(array_map( - fn (array $clause): array => array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $clause), - $body->clauses, - )); + $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; } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index ab0124b..675d055 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -326,25 +326,61 @@ public function testTransitiveClosureSignatureAliasPropagatesInASlotAndRejectsEl 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 in a union or an intersection has no representation — - // unsupported. The full message is asserted so a reworded / reordered diagnostic is caught. - $combinedMessage = 'has an unsupported body: a closure signature cannot be combined with ' - . 'another type in a union or intersection.'; - foreach ([ - 'union' => 'type U = A | Foo;', - 'intersection' => 'type U = A & Foo;', - ] as $decl) { - $combined = ['C.xphp' => $header . "{$decl}\nclass Svc { public U \$h; }\n"]; - self::assertRejected($this->check($combined), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $combinedMessage); - $this->assertCompileThrows($combined, $combinedMessage); - } + // 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 has no bound representation — compound-in-non-slot. + // 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 @@ -769,15 +805,9 @@ public function testUnsupportedAliasBodyIsRejectedInBothModes(): void foreach ([ // A body the bound-expression reader cannot read (leads with `|`) — declined, not crashed. 'malformed' => 'type Bad = |A;', - // A closure signature combined with a union (`A | Closure(...)`) is out of scope — the - // bound-expression reader declines it. (A union RETURN type, `Closure(int): int | A`, is a - // valid closure signature and is accepted.) - 'closure-after-union' => 'type Bad = A | Closure(int): int;', // 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;', - // A nullable closure signature — out of scope (declined by the leading-`?` reader). - 'nullable-closure' => 'type Bad = ?Closure(int): int;', ] as $body) { $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); diff --git a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp index 79e509a..efd11bc 100644 --- a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp +++ b/test/fixture/compile/closure_sig_alias/source/Handlers.xphp @@ -11,16 +11,25 @@ type Mapper = 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) + 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 @@ -39,7 +48,8 @@ class Registry } } -$reg = new Registry(fn(int $x): bool => $x > 0, fn(int $x): bool => $x < 0); +$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 index 45789f6..408fb44 100644 --- a/test/fixture/compile/closure_sig_alias/verify/runtime.php +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -25,6 +25,10 @@ // 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'); }; From 3ef9b2a2c02c80b0182a3ce8c46428864345013d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 18:22:12 +0000 Subject: [PATCH 11/15] docs(type-aliases): a closure signature can be a compound member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the caveats, syntax tour, error catalog, roadmap, comparison grid, and changelog: a closure signature is now usable as a nullable / union / intersection member of an alias body (erasing to `\Closure` inside the `?`/`|`/`&`), with parity to the directly-written slot types. The only remaining body-shape limit is at most one closure per body — every closure erases to the same `\Closure`, and PHP forbids a duplicate `\Closure|\Closure`. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++-- docs/caveats.md | 34 +++++++++++++++++++--------------- docs/errors.md | 2 +- docs/guides/comparison.md | 2 +- docs/roadmap.md | 24 ++++++++++++------------ docs/syntax/type-aliases.md | 17 +++++++++++------ 6 files changed, 47 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03caed5..0d0e517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (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 compound (union / nullable / intersection / DNF / closure @@ -30,8 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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` — e.g. a closure signature mixed - into a union/nullable), compound-in-non-slot (`xphp.alias_compound_in_non_slot`), distribution-requiring + 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 diff --git a/docs/caveats.md b/docs/caveats.md index 87a309d..b8e47d6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -95,16 +95,19 @@ behavior, only makes the type explicit. **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`), a nullable (`?Box`), an intersection (`A & B`), a DNF (`(A & B) | C`), and a closure -signature (`Closure(int): bool`) body are all supported; parameters may carry defaults -and bounds. The remaining limits are on the body shape (a closure combined with a -union/nullable, distribution) and the positions a compound alias can take. +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 Fn = Closure(int): int; // ✓ closure signature — erases to \Closure in a whole slot -type Bad = A | Closure(int): int;// ✗ xphp.alias_unsupported_body — closure combined with a union -type Nul = ?Closure(int): int; // ✗ xphp.alias_unsupported_body — nullable closure signature +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 @@ -151,18 +154,19 @@ A compound body (union / intersection / nullable / DNF / closure signature) lowe 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: a **closure signature -mixed into a union/nullable** (a plain `Closure(...)` body works; `A | Closure(...)` -does not — the signature rides a bare `\Closure` only), 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. +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 a closure body mixed with a union/nullable, use a bare `\Closure` in the - union, or write the type directly. For a `(A|B)&C` body, write the DNF `(A&C)|(B&C)`. +- 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 5155b55..4a6456f 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -60,7 +60,7 @@ 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 union, an intersection, a nullable, a DNF, or a closure signature — e.g. a closure signature combined with a union (`A \| Closure(int): int`) or nullable (`?Closure(int): int`) | +| `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 | diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 5c544c2..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 / intersection / DNF / closure-signature bodies, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and a closure signature combined with a union/nullable isn'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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2199f15..630733e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -241,24 +241,24 @@ upcoming one. - 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`) bodies. A single head expands in every type position (incl. - as a generic argument, `Bag`); a compound (union / nullable / - intersection / DNF) or a 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. + 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 (a - closure signature mixed into a union/nullable), 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. +- 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/type-aliases.md b/docs/syntax/type-aliases.md index 47fc3aa..176918a 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -107,8 +107,9 @@ no separate code path and no runtime cost. class, interface, or trait of the same name (no silent shadowing). - `xphp.alias_duplicate` — the same alias name declared twice. - `xphp.alias_unsupported_body` — a body that is none of the supported - shapes, e.g. a closure signature combined with a union (`A | Closure(...)`) - or nullable (`?Closure(...)`) (see caveats below). + 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 @@ -128,10 +129,14 @@ 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.) -- **A closure signature combined with a union or nullable** - (`A | Closure(int): int`, `?Closure(int): int`) is - `xphp.alias_unsupported_body` — a plain `Closure(...)` body works; a mixed - one does not. (A bare `\Closure` may be combined freely.) +- **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 From 87fe46f2105d728dbd3b490743c55d84426b7ceb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 18:38:12 +0000 Subject: [PATCH 12/15] refactor(monomorphize): tighten closure-in-compound parse for mutation coverage Fold the whole-body / nullable closure recognition into a shared readAliasDnf helper (the `?`-reader now reuses parseBoundExpr, dropping tryWholeBodyClosureSig and its edge-guard mutants). Make the bound-expression reader's allowClosureSig / source parameters required (they are always passed explicitly, so the defaults were dead). Mark the two dead-value mutants (the signature `nullable` flag, unread by conformance; the defensive `\Closure` ltrim) as equivalent, and make isClosureErasing private. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/AliasBody.php | 5 +- .../Monomorphize/XphpSourceParser.php | 103 ++++++++---------- 2 files changed, 52 insertions(+), 56 deletions(-) diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php index d539ad4..921c7e2 100644 --- a/src/Transpiler/Monomorphize/AliasBody.php +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -90,8 +90,11 @@ public function closureLeafCount(): int * Whether a leaf erases to a bare `\Closure` — a {@see ClosureSignature}, or a `\Closure`-named * TypeRef (case-insensitive, fully-qualified or not). */ - public static function isClosureErasing(TypeRef|ClosureSignature $leaf): bool + 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'); } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 9d70285..7cf0ca5 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1990,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; } @@ -2225,7 +2225,7 @@ 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, bool $allowClosureSig = false, ?string $source = null): ?array + private static function parseBoundExpr(array $tokens, int $startIdx, bool $allowClosureSig, ?string $source): ?array { return self::parseOrBound($tokens, $startIdx, $allowClosureSig, $source); } @@ -2234,7 +2234,7 @@ private static function parseBoundExpr(array $tokens, int $startIdx, bool $allow * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseOrBound(array $tokens, int $idx, bool $allowClosureSig = false, ?string $source = null): ?array + private static function parseOrBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $first = self::parseAndBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { @@ -2266,7 +2266,7 @@ private static function parseOrBound(array $tokens, int $idx, bool $allowClosure * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseAndBound(array $tokens, int $idx, bool $allowClosureSig = false, ?string $source = null): ?array + private static function parseAndBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $first = self::parsePrimaryBound($tokens, $idx, $allowClosureSig, $source); if ($first === null) { @@ -2298,7 +2298,7 @@ private static function parseAndBound(array $tokens, int $idx, bool $allowClosur * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parsePrimaryBound(array $tokens, int $idx, bool $allowClosureSig = false, ?string $source = null): ?array + private static function parsePrimaryBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $n = count($tokens); if ($idx >= $n) { @@ -2327,7 +2327,7 @@ private static function parsePrimaryBound(array $tokens, int $idx, bool $allowCl * @param list $tokens * @return array{0: BoundDict, 1: int}|null */ - private static function parseLeafBound(array $tokens, int $idx, bool $allowClosureSig = false, ?string $source = null): ?array + private static function parseLeafBound(array $tokens, int $idx, bool $allowClosureSig, ?string $source): ?array { $n = count($tokens); if ($idx >= $n || !self::isNameToken($tokens[$idx])) { @@ -2356,6 +2356,9 @@ private static function parseLeafBound(array $tokens, int $idx, bool $allowClosu 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, @@ -2579,33 +2582,22 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI // @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 === '?') { - $after = self::skipWs($tokens, $bodyStart + 1); - $sig = self::tryWholeBodyClosureSig($tokens, $after, $semiIdx, $source); - if ($sig !== null) { - return [new AliasBody([[$sig], [new TypeRef('null')]]), false]; - } - $parsed = self::parseTypeArg($tokens, $after); - if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + // `?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 [new AliasBody([[$parsed[0]], [new TypeRef('null')]]), false]; + return [new AliasBody([$dnf[0], [new TypeRef('null')]]), false]; } - // Otherwise read a full `|` / `&` type expression (with `(` … `)` groups) via the shared - // bound-expression reader — allowing a `Closure(...)` signature leaf, which erases to `\Closure` - // (so `Closure(...)`, `Foo | Closure(...)`, `Foo & Closure(...)` all read). A body that would - // need distribution (a union inside an intersection) declines with the distribution flag set. - // Any other reader throw (a malformed closure in a real-bound context can't reach here) declines. - try { - $parsed = self::parseBoundExpr($tokens, $bodyStart, allowClosureSig: true, source: $source); - } catch (XphpParseException) { - return [null, false]; - } - if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + // 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]; } - $dnf = self::boundTreeToDnf($parsed[0]); - if ($dnf === null) { + if ($dnf === self::DNF_NEEDS_DISTRIBUTION) { return [null, true]; } // @infection-ignore-all FalseValue -- the distribution flag rides alongside a NON-null body @@ -2614,28 +2606,29 @@ private static function parseAliasBody(array $tokens, int $bodyStart, int $semiI 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'; + /** - * If a `Closure(params): return` signature begins at $idx and spans exactly to the terminator - * $semiIdx (a whole-body closure), return the parsed {@see ClosureSignature}; otherwise null. Uses - * the ungated core (the gated `tryParseClosureSignature` needs a following `$var`/return slot, which - * a `;`-terminated alias body is not). + * 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 tryWholeBodyClosureSig(array $tokens, int $idx, int $semiIdx, string $source): ?ClosureSignature + private static function readAliasDnf(array $tokens, int $start, int $semiIdx, string $source): array|string|null { - if (($tokens[$idx] ?? null) === null || ltrim($tokens[$idx]->text, '\\') !== 'Closure') { - return null; - } - $openIdx = self::skipWs($tokens, $idx + 1); - if ($openIdx >= $semiIdx || !($tokens[$openIdx]->text === '(' || self::isCastToken($tokens[$openIdx]))) { + try { + $parsed = self::parseBoundExpr($tokens, $start, allowClosureSig: true, source: $source); + } catch (XphpParseException) { return null; } - $spanEnd = self::findClosureSigEnd($tokens, $openIdx); - if ($spanEnd === null || self::skipWs($tokens, $spanEnd + 1) !== $semiIdx) { + if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { return null; } - return self::buildClosureSignature($tokens, $openIdx, $source, false); + return self::boundTreeToDnf($parsed[0]) ?? self::DNF_NEEDS_DISTRIBUTION; } /** @@ -4485,21 +4478,21 @@ private function expandAliasToDnf(TypeRef $ref, array $visited, int $line): Alia foreach ($bodyClause as $leaf) { if ($leaf instanceof ClosureSignature) { $merged[] = Specializer::substituteClosureSignature($leaf, $aliasSubst); - continue; - } - $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; + } 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; From 7db0d644c0da8bf6fec47ad494ee264ed3718c08 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 19:23:11 +0000 Subject: [PATCH 13/15] test(monomorphize): name the closure fixture source after its class Rename the closure_sig_alias fixture source Handlers.xphp -> Registry.xphp so the file name matches its primary class, and point the runtime verify at the emitted Registry.php. The output file is named by source basename, so the mismatch broke the require. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../closure_sig_alias/source/{Handlers.xphp => Registry.xphp} | 0 test/fixture/compile/closure_sig_alias/verify/runtime.php | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/fixture/compile/closure_sig_alias/source/{Handlers.xphp => Registry.xphp} (100%) diff --git a/test/fixture/compile/closure_sig_alias/source/Handlers.xphp b/test/fixture/compile/closure_sig_alias/source/Registry.xphp similarity index 100% rename from test/fixture/compile/closure_sig_alias/source/Handlers.xphp rename to test/fixture/compile/closure_sig_alias/source/Registry.xphp diff --git a/test/fixture/compile/closure_sig_alias/verify/runtime.php b/test/fixture/compile/closure_sig_alias/verify/runtime.php index 408fb44..663757a 100644 --- a/test/fixture/compile/closure_sig_alias/verify/runtime.php +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -16,7 +16,7 @@ use XPHP\TestSupport\CompiledFixture; return function (CompiledFixture $fixture): void { - require $fixture->targetDir . '/Handlers.php'; + 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'); From db1a1c5d3e4f622aa1bfec7a870fbdb1a35c165a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 7 Aug 2026 22:30:10 +0000 Subject: [PATCH 14/15] fix(monomorphize): make alias member dedup case-insensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP class-like names are case-insensitive, so a compound alias body that repeats one class in different casing (`type X = Foo & foo`) named the same class in both leaves — yet the dedup key used the case-preserving canonical name, so both survived and the slot emitted `\App\Foo&\App\foo`, which PHP rejects at load as a duplicate-type fatal. The same held for a union arm (`Foo | foo` → `\App\Foo|\App\foo`). Lowercase the leaf key so it matches PHP's own case-insensitive class resolution; both the intersection (`dedupeLeaves`) and union (`dedupeClauses`) paths route through it, so one change covers both. Genuinely distinct members (`A & B`) are untouched. Aligns the key with `isClosureErasing`, which already lowercases the class name. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/AliasBody.php | 6 ++++-- .../Monomorphize/TypeAliasIntegrationTest.php | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/AliasBody.php b/src/Transpiler/Monomorphize/AliasBody.php index 921c7e2..c249f3b 100644 --- a/src/Transpiler/Monomorphize/AliasBody.php +++ b/src/Transpiler/Monomorphize/AliasBody.php @@ -101,11 +101,13 @@ private static function isClosureErasing(TypeRef|ClosureSignature $leaf): bool /** * The dedup key of a leaf: every signature (and every `\Closure` TypeRef) keys to `\Closure`; a - * plain TypeRef keys to its canonical name. + * 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' : $leaf->canonical(); + return $leaf instanceof ClosureSignature ? '\\closure' : strtolower($leaf->canonical()); } /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 675d055..7bf0490 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -236,9 +236,11 @@ public function testRedundantIntersectionAndUnionMembersAreDeduped(): void // A duplicate member in an intersection / union is a PHP "Duplicate type … is redundant" PARSE // fatal — it would take down the whole emitted file. Duplicates are identity-preserving, so they // collapse: `A&A` ≡ `A`, `A&B&B` ≡ `A&B` (a composed alias reintroducing a member), `A|A` ≡ `A`. - // The emitted file must load — asserted structurally here and executed in the runtime fixture. + // PHP class-like names are case-insensitive, so `Foo & foo` / `Foo | foo` name the SAME class and + // must collapse too — a case-sensitive dedup key would let both survive and emit an `\App\Foo&\App\foo` + // duplicate-type fatal. The emitted file must load — asserted structurally here and executed in the runtime fixture. $use = self::read($this->compile([ - 'Use.xphp' => " " Date: Sun, 9 Aug 2026 10:40:43 +0000 Subject: [PATCH 15/15] test(monomorphize): cover null-valued nullable closure-sig slot at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `?Closure(int): bool` alias member erases to `?\Closure`, so null is a legal slot value and xphp injects no null-guard. Extend the closure-sig runtime fixture to construct the registry with a null `MaybeCheck`, invoke it, and assert the emitted program throws PHP's own `Value of type null is not callable` Error — proving the erased nullable slot behaves exactly as a hand-written `?\Closure`, null-callability check and all, rather than the transpiler swallowing or guarding it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compile/closure_sig_alias/source/Registry.xphp | 12 ++++++++++++ .../compile/closure_sig_alias/verify/runtime.php | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/test/fixture/compile/closure_sig_alias/source/Registry.xphp b/test/fixture/compile/closure_sig_alias/source/Registry.xphp index efd11bc..4b7014a 100644 --- a/test/fixture/compile/closure_sig_alias/source/Registry.xphp +++ b/test/fixture/compile/closure_sig_alias/source/Registry.xphp @@ -53,3 +53,15 @@ $ok = $reg->run(5); $aliasOk = $reg->viaAlias(-2); $maybeOk = $reg->viaMaybe(0); $mapped = $reg->map(fn(int $x): string => "n{$x}", 3); + +// The `MaybeCheck` (`?Closure(int): bool`) slot erases to `?\Closure`, so null is a legal value — the +// constructor accepts it and the file loads. xphp injects NO null-guard around the erased slot, so +// invoking a null-valued slot throws PHP's own "not callable" Error, exactly as a hand-written +// `?\Closure` would. Capturing the message proves the null-callability check is present and un-suppressed. +$nullReg = new Registry(fn(int $x): bool => $x > 0, fn(int $x): bool => $x < 0, null); +$nullCallError = null; +try { + $nullReg->viaMaybe(0); +} catch (\Error $e) { + $nullCallError = $e->getMessage(); +} diff --git a/test/fixture/compile/closure_sig_alias/verify/runtime.php b/test/fixture/compile/closure_sig_alias/verify/runtime.php index 663757a..50aa06e 100644 --- a/test/fixture/compile/closure_sig_alias/verify/runtime.php +++ b/test/fixture/compile/closure_sig_alias/verify/runtime.php @@ -31,4 +31,12 @@ // 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'); + + // The `?\Closure` slot legally held null (the constructor accepted it and the file loaded); invoking + // it threw PHP's own "not callable" Error — xphp injects no null-guard around the erased nullable slot. + Assert::assertSame( + 'Value of type null is not callable', + $nullCallError, + 'invoking a null-valued nullable closure slot throws PHP\'s own not-callable Error, un-guarded', + ); };