From 4e39e0d3f489b3ff8c40141b9b68e496c129bed2 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 08:30:31 +0000 Subject: [PATCH 01/17] feat(monomorphize): strip type-alias declarations at scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize a `type Name[] = SingleHeadBody;` declaration in the scanner and blank it to equal-length whitespace, so the (otherwise invalid) statement never reaches the host PHP parser. The alias arm runs first — ahead of the bare `Name<…>` arm — so the `` clauses on the alias head and its body are not half-stripped. This is the recognition + strip step only: the alias body is not yet captured or expanded (that follows). Statement-position gated so `type` used as a constant, function, or member name is never mistaken for a declaration. Single-head bodies only; a union / intersection / nullable body, or any other non-single-head shape, declines here and falls through (to become an explicit diagnostic in a later change). The separator must be `=`; the param list is parsed permissively. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 107 +++++++++++ .../Monomorphize/XphpSourceParserTest.php | 176 ++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index bf5c9e12..00fe193d 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -328,6 +328,25 @@ private function scanAndStrip(string $source): array while ($i < $n) { $tok = $tokens[$i]; + // Type-alias declaration: `type Name[] = SingleHeadBody;` (WI-01, file-local). + // `type` is a contextual keyword (an ordinary T_STRING), so this arm MUST run first — + // before the bare `Name<…>` arm below, which would otherwise strip the `` off + // `type Pair = …` and leave the statement half-parsed. `tryParseAliasDeclaration` + // gates on statement position (so a `type` used as a constant / function / member name + // 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') { + $semicolonIdx = self::tryParseAliasDeclaration($tokens, $i); + if ($semicolonIdx !== null) { + $startByte = $tok->pos; + $endByte = $tokens[$semicolonIdx]->pos + strlen($tokens[$semicolonIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; + $i = $semicolonIdx + 1; + continue; + } + } + // Anonymous closure: `function(...){}` / `fn(...)`. // Recognized by T_FUNCTION/T_FN followed immediately by `<` (no // T_STRING name). `static`-prefixed shapes are consumed by the @@ -2385,6 +2404,94 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array return null; } + /** + * Recognize a type-alias declaration `type Name [] = SingleHeadBody;` beginning at the + * `type` token index `$typeIdx`, and return the index of its terminating `;` — or null when the + * tokens are not a well-formed single-head alias declaration, so the `type` token falls through + * to ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing + * is silently eaten). + * + * v1 (WI-01): file-local; the body must be a single (possibly-generic) head that `parseTypeArg` + * accepts. A union / intersection / nullable / closure body leaves a non-`;` token after the head + * and is declined here — a dedicated `xphp.alias_unsupported_body` diagnostic lands in a later + * change rather than a silent pass-through. + * + * Gated to STATEMENT position: the previous significant token must be a statement boundary + * (`;`, `{`, `}`, or the opening `type`, `new type()`) is never mistaken for a declaration. + * + * @param list $tokens + */ + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?int + { + // 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 + // a constant / function / member name (preceded by `->`, `::`, `=`, `(`, …) is declined here. + $prevTok = $tokens[self::skipWsBack($tokens, $typeIdx - 1)] ?? null; + if ($prevTok === null + || !($prevTok->id === T_OPEN_TAG + || $prevTok->text === ';' + || $prevTok->text === '{' + || $prevTok->text === '}') + ) { + return null; + } + + // Alias name. + // @infection-ignore-all IncrementInteger -- `type` is always followed by whitespace (else + // `typeName` would tokenize as one T_STRING), so skipWs(+1) and skipWs(+2) reach the same + // name token: the offset increment is an equivalent mutant. + $nameIdx = self::skipWs($tokens, $typeIdx + 1); + $nameTok = $tokens[$nameIdx] ?? null; + if ($nameTok === null || $nameTok->id !== T_STRING) { + return null; + } + + // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on + // a class header) so recognition never throws; whether an alias param may carry a default or + // variance marker is a semantic question for the expansion step, not for scan-time stripping. + $afterName = self::skipWs($tokens, $nameIdx + 1); + $afterNameTok = $tokens[$afterName] ?? null; + if ($afterNameTok === null) { + return null; + } + if ($afterNameTok->text === '<') { + $parsed = self::parseTypeParamList($tokens, $afterName, allowDefaults: true, allowVariance: true); + if ($parsed === null) { + return null; + } + [, $paramsEndIdx] = $parsed; + // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed + // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison + // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. + $eqIdx = self::skipWs($tokens, $paramsEndIdx + 1); + } else { + $eqIdx = $afterName; + } + + // `=`. + if (($tokens[$eqIdx] ?? null)?->text !== '=') { + return null; + } + + // Single (possibly-generic) head body. A union / intersection / nullable / closure body + // leaves a non-`;` token after the head and is declined for v1 (a later change turns that + // into an explicit `xphp.alias_unsupported_body` diagnostic rather than a silent decline). + $bodyParsed = self::parseTypeArg($tokens, self::skipWs($tokens, $eqIdx + 1)); + if ($bodyParsed === null) { + return null; + } + [, $afterBody] = $bodyParsed; + + // Terminating `;` (a single-head body leaves it immediately after the head). + $semiIdx = self::skipWs($tokens, $afterBody); + if (($tokens[$semiIdx] ?? null)?->text !== ';') { + return null; + } + + return $semiIdx; + } + /** * Parse a single type arg: `NAME ( < TypeArgList > )?`. * diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b35b9d2b..a0ed6357 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -85,6 +85,169 @@ trait HasTimestamps self::assertStringNotContainsString('trait HasTimestamps', $printed); } + public function testTypeAliasDeclarationsAreStrippedAndParseCleanly(): void + { + // WI-01 (Commit 1): a `type Name[<…>] = SingleHead;` declaration is recognized at scan and + // blanked to equal-length whitespace, so the (otherwise invalid) statement never reaches + // nikic. The alias arm MUST run before the bare `Name<…>` arm, or the `` clauses on + // `Pair`/`Map` get half-stripped and the RHS is left dangling (CRITICAL-4, design review). + $source = <<<'PHP' + = Map; +type UserId = \App\Id; +type Ints = Bag; + +class Repo +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Each declaration span becomes equal-length whitespace; everything else is byte-identical. + self::assertSame( + self::withBlanked( + $source, + 'type Pair = Map;', + 'type UserId = \App\Id;', + 'type Ints = Bag;', + ), + $parser->strip($source), + ); + + // The whole file still parses; the alias statements are gone, the class remains. + $class = self::findFirstClass($parser->parse($source)); + self::assertNotNull($class); + self::assertSame('Repo', $class->name?->toString()); + } + + public function testNonGenericTypeAliasWithoutParamsIsStripped(): void + { + // The no-`<…>` shape (`type Name = Body;`) must strip too, and the trailing statement + // survives untouched. + $source = "createForHostVersion()); + + self::assertSame(self::withBlanked($source, 'type UserId = int;'), $parser->strip($source)); + $parser->parse($source); // must not throw + } + + /** + * A type-alias declaration is recognized at every statement boundary (open tag, `;`, `{`, `}`), + * including with no separating whitespace — the statement-position guard must accept each. + */ + public function testTypeAliasRecognizedAtEveryStatementBoundary(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // `{` boundary, no space before `type` (guards the skipWsBack offset + the `{` branch). + $braceOpen = "strip($braceOpen)); + + // `}` boundary, right after a class close (guards the `}` branch). + $braceClose = "strip($braceClose)); + + // `;` boundary — a second alias directly after the first; both spans are blanked. + $semi = "strip($semi), + ); + + // Parameter lists are parsed permissively (defaults + variance), so these are recognized + // and blanked rather than throwing — the `allowDefaults` / `allowVariance` flags. + $defaulted = " = Bag;\n"; + self::assertSame(self::withBlanked($defaulted, 'type P = Bag;'), $parser->strip($defaulted)); + $variant = " = Bag;\n"; + self::assertSame(self::withBlanked($variant, 'type B = Bag;'), $parser->strip($variant)); + } + + /** + * `type` is a contextual keyword: only a statement-position `type Name = …` is a declaration. + * A property/constant/expression use named `type` must be left byte-for-byte untouched — and a + * `type` reached in a non-statement position must never be read as a declaration head even when + * a `Name = Body` pattern follows it. + */ + public function testTypeOutsideStatementPositionIsNeverAnAliasDeclaration(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $memberish = "type;\n\$b = Foo::type;\n"; + self::assertSame($memberish, $parser->strip($memberish), '`type` in member/constant position must not be stripped'); + + // `->type Foo = int` is not a statement; without the guard it would be mis-read as a + // declaration and wrongly stripped. It must be left intact. + $notADecl = "type Foo = int;\n"; + self::assertSame($notADecl, $parser->strip($notADecl)); + + $parser->parse($memberish); // must not throw + } + + /** + * v1 recognizes only a single (possibly-generic) head body. A union / intersection / nullable + * body is declined at scan (left intact), to become an explicit diagnostic in a later change — + * it must never be half-stripped. + */ + public function testNonSingleHeadAliasBodiesAreDeclined(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Union / nullable bodies: parseTypeArg stops at `|` / returns null on `?`, leaving a + // non-`;` token after the head, so the declaration is declined and left byte-for-byte intact. + $union = "strip($union)); + $nullable = "strip($nullable)); + // The alias name must be a real identifier (T_STRING). A reserved word like `array` + // (T_ARRAY) is not a valid alias head, so the declaration is declined and left intact. + $reserved = "strip($reserved)); + } + + /** + * Whitespace around the `=` and after the head is optional — a tightly-spelled `type X=Y;` is + * recognized and stripped just like the spaced form. And a generic-parameter head that runs out + * at end of input (no `=`) is declined without crashing on the (missing) `=` token. + */ + public function testTightlySpelledAndGenericEofAliasShapes(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // No spaces around `=` — still a single-head body, recognized and blanked. + $tight = "strip($tight)); + + // The separator must be `=`; the rejected colon spelling `type X : Foo` is declined intact. + $colon = "strip($colon)); + + // Generic params then EOF (no `=`): the alias arm declines at the `=` check without + // dereferencing the missing token; only the bare `` is cleaned by the downstream name + // arm, so the `type X` head survives. + $eof = ""; + self::assertSame(self::withBlanked($eof, ''), $parser->strip($eof)); + } + + /** + * A truncated / unterminated `type …` at end of input is declined without crashing — the token + * stream simply runs out at each parse step. Guards the end-of-stream floors in the recognizer + * (each step's `?? null`), which the tolerant LSP path relies on for half-typed code. + */ + public function testTruncatedTypeAliasAtEndOfInputIsDeclinedWithoutCrashing(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + foreach ([ + "strip($truncated), 'truncated alias must be left intact'); + } + } + public function testAttachesGenericParamsToTraitDefinition(): void { // Traits ride the same ClassLike pathway as classes/interfaces. Locks the @@ -1042,6 +1205,19 @@ private static function paramNames(\PhpParser\Node\Stmt\ClassLike $node): array return array_map(static fn (TypeParam $p): string => $p->name, $params); } + /** + * Return `$source` with each (single-line) `$span` replaced by equal-length spaces — the exact + * transformation `XphpSourceParser::strip()` applies to a recognized declaration. Lets a strip + * assertion state the full expected output deterministically rather than a substring check. + */ + private static function withBlanked(string $source, string ...$spans): string + { + foreach ($spans as $span) { + $source = str_replace($span, str_repeat(' ', strlen($span)), $source); + } + return $source; + } + /** @param array $ast */ private static function findFirstClass(array $ast): ?Class_ { From 5c0302c3b674a12a0c2bb79c1927130d500934b5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 12:18:17 +0000 Subject: [PATCH 02/17] feat(monomorphize): expand type-alias uses before specialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture each `type Name[<…>] = SingleHead;` declaration and expand its uses into the alias body before specialization, so nothing downstream (registry, specializer, call-site rewriter) ever sees an alias and the emitted PHP contains no alias name. - Build a file-local alias table keyed by FQN, attributing each alias to its declaring namespace by byte span (a real class sharing an alias's short name in another namespace never collides). - Expand in the resolver's Name branch: the head AND, recursively, the arguments (an alias can appear as a generic argument, e.g. Bag), substituting parameters via the resolved body. Nested and concrete-instantiation aliases (UserMap = Pair) resolve fully; a non-alias name is left untouched. - Reject a self-referential (cyclic) or arity-mismatched alias loudly in both modes: compile throws, check collects the diagnostic. Aliases are a pure compile-time substitution with no runtime existence; v1 is file-local and single-head-bodied. A runtime fixture executes the compiled output and asserts every alias name is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 268 +++++++++++++++-- .../Monomorphize/TypeAliasIntegrationTest.php | 281 ++++++++++++++++++ .../compile/type_aliases/source/Types.xphp | 51 ++++ .../compile/type_aliases/verify/runtime.php | 38 +++ 4 files changed, 619 insertions(+), 19 deletions(-) create mode 100644 test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php create mode 100644 test/fixture/compile/type_aliases/source/Types.xphp create mode 100644 test/fixture/compile/type_aliases/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 00fe193d..81cc5ef1 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -151,7 +151,7 @@ public function parse(string $source): array */ public function parseWithMap(string $source): array { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); try { $ast = $this->parser->parse($cleanedSource); @@ -169,7 +169,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -273,7 +273,7 @@ public function parseTolerant(string $source): ?array */ public function parseTolerantWithMap(string $source): ?ParseWithMapResult { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); $errorHandler = new \PhpParser\ErrorHandler\Collecting(); $ast = $this->parser->parse($cleanedSource, $errorHandler); @@ -282,7 +282,7 @@ public function parseTolerantWithMap(string $source): ?ParseWithMapResult } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); return new ParseWithMapResult($ast, $byteOffsetMap); } @@ -307,7 +307,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:TypeRef, bytePosition:int}>} */ private function scanAndStrip(string $source): array { @@ -321,6 +321,8 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; + /** @var list, body:TypeRef, bytePosition:int}> $aliasMarkers */ + $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -336,8 +338,10 @@ 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') { - $semicolonIdx = self::tryParseAliasDeclaration($tokens, $i); - if ($semicolonIdx !== null) { + $aliasParsed = self::tryParseAliasDeclaration($tokens, $i); + if ($aliasParsed !== null) { + [$aliasMarker, $semicolonIdx] = $aliasParsed; + $aliasMarkers[] = $aliasMarker; $startByte = $tok->pos; $endByte = $tokens[$semicolonIdx]->pos + strlen($tokens[$semicolonIdx]->text); $length = $endByte - $startByte; @@ -847,7 +851,7 @@ private function scanAndStrip(string $source): array $cleaned = self::applyReplacements($source, $replacements); $byteOffsetMap = ByteOffsetMap::fromReplacements($replacements); - return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers]; + return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers, $aliasMarkers]; } /** @@ -2406,10 +2410,12 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array /** * Recognize a type-alias declaration `type Name [] = SingleHeadBody;` beginning at the - * `type` token index `$typeIdx`, and return the index of its terminating `;` — or null when the - * tokens are not a well-formed single-head alias declaration, so the `type` token falls through - * to ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing - * is silently eaten). + * `type` token index `$typeIdx`, and return `[marker, semicolonIndex]` — or null when the tokens + * are not a well-formed single-head alias declaration, so the `type` token falls through to + * ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing is + * silently eaten). The marker carries the alias short name, its (possibly empty) type-parameter + * names, the raw body TypeRef (resolved later against the namespace context), and the `type` + * token's byte position for namespace-span attribution. * * v1 (WI-01): file-local; the body must be a single (possibly-generic) head that `parseTypeArg` * accepts. A union / intersection / nullable / closure body leaves a non-`;` token after the head @@ -2421,8 +2427,9 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens + * @return array{0: array{name:string, paramNames:list, body:TypeRef, bytePosition:int}, 1: int}|null */ - private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?int + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?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 @@ -2450,6 +2457,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on // a class header) so recognition never throws; whether an alias param may carry a default or // variance marker is a semantic question for the expansion step, not for scan-time stripping. + $paramNames = []; $afterName = self::skipWs($tokens, $nameIdx + 1); $afterNameTok = $tokens[$afterName] ?? null; if ($afterNameTok === null) { @@ -2460,7 +2468,8 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($parsed === null) { return null; } - [, $paramsEndIdx] = $parsed; + [$paramEntries, $paramsEndIdx] = $parsed; + $paramNames = array_map(static fn (array $entry): string => $entry['name'], $paramEntries); // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. @@ -2481,7 +2490,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($bodyParsed === null) { return null; } - [, $afterBody] = $bodyParsed; + [$body, $afterBody] = $bodyParsed; // Terminating `;` (a single-head body leaves it immediately after the head). $semiIdx = self::skipWs($tokens, $afterBody); @@ -2489,7 +2498,15 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - return $semiIdx; + return [ + [ + 'name' => $nameTok->text, + 'paramNames' => $paramNames, + 'body' => $body, + 'bytePosition' => $tokens[$typeIdx]->pos, + ], + $semiIdx, + ]; } /** @@ -2783,6 +2800,58 @@ private static function applyReplacements(string $source, array $replacements): return $source; } + /** + * Build the file-local type-alias table, keyed by fully-qualified name. Each alias's declaring + * namespace is found by locating the `Namespace_` node whose (original-source) byte span contains + * the `type` keyword, so a real class sharing an alias's short name in another namespace never + * collides. Bodies stay raw (unresolved) — they resolve lazily at expansion, when the use-site + * namespace context is available. A duplicate FQN keeps the last declaration (a dedicated + * duplicate-alias diagnostic lands in a later change). + * + * @param list $ast + * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @return array, body:TypeRef}> + */ + private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array + { + // @infection-ignore-all ReturnRemoval -- optimization only: with no markers the loops below + // produce an empty table anyway; the early return just skips the namespace-span walk for the + // common alias-free file. + if ($aliasMarkers === []) { + return []; + } + /** @var list $spans namespace name + original byte span */ + $spans = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $spans[] = [ + $stmt->name?->toString() ?? '', + $byteOffsetMap->toOriginal($stmt->getStartFilePos()), + $byteOffsetMap->toOriginal($stmt->getEndFilePos()), + ]; + } + } + $table = []; + foreach ($aliasMarkers as $marker) { + $namespace = ''; + foreach ($spans as [$name, $start, $end]) { + // @infection-ignore-all GreaterThanOrEqualTo LessThanOrEqualTo -- a `type` keyword's + // byte sits strictly inside its namespace span (after the `namespace` keyword, before + // the closing brace / EOF), so the `>=`/`<=` boundary variants never shift attribution; + // the `&&` (a use in an earlier namespace must not match a later one) is exercised. + if ($marker['bytePosition'] >= $start && $marker['bytePosition'] <= $end) { + $namespace = $name; + // @infection-ignore-all Break_ -- namespace spans are disjoint, so no later span + // can also contain this byte; continuing the loop is equivalent. + break; + } + } + $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; + $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; + } + return $table; + } + /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * @@ -2803,15 +2872,17 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers + * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { + $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); $traverser = new NodeTraverser(); $visitor = new /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -2826,6 +2897,8 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers + * @param array, body:TypeRef}> $aliasTable file-local + * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( private array $classMarkers, @@ -2833,11 +2906,22 @@ public function __construct( private array $methodMarkers, private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, + private array $aliasTable, ) { $this->ctx = new NamespaceContext(); } - public function enterNode(Node $node): null + /** + * 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 + */ + private array $aliasBodyCache = []; + + // Returns a replacement Node when a type-alias use is expanded in place (the traverser + // swaps it into the parent slot); null in every other case leaves the node untouched. + public function enterNode(Node $node): ?Node { if ($node instanceof Use_ || $node instanceof GroupUse) { // Reject a generic clause on a namespace-import BEFORE the blanket @@ -3162,6 +3246,16 @@ public function enterNode(Node $node): null break; } } + + // Alias expansion (WI-01): if this type-position Name resolves to a declared + // single-head alias, replace it with the recursively-expanded body so nothing + // downstream (registry, specializer, call-site rewriter) ever sees the alias. + // Runs after marker binding (a generic use's args are on the node by now) and + // after the parent slot's markName (a bare use's ATTR_RESOLVED_FQN is set). + $expansion = $this->expandAliasName($node); + if ($expansion !== null) { + return $expansion; + } } // Tag bare class/interface Name references in class-name positions @@ -3744,6 +3838,142 @@ private function resolveTypeRef(TypeRef $ref): TypeRef ); } + /** + * If this type-position Name resolves to a declared single-head alias, return the AST + * node for its fully-expanded body; otherwise null (leave the node untouched). The use's + * head + arguments come from the attributes already attached: a generic use carries + * ATTR_GENERIC_ARGS + ATTR_TEMPLATE_FQN, a bare use carries ATTR_RESOLVED_FQN. A Name in a + * non-type position (a plain function call) has neither and is skipped. + */ + private function expandAliasName(Name $node): ?Node + { + // @infection-ignore-all ReturnRemoval -- optimization only: with an empty table the + // `isset($this->aliasTable[$head])` guard below already returns null for every name. + if ($this->aliasTable === []) { + return null; + } + $genericArgs = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $templateFqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $resolvedFqn = $node->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + // @infection-ignore-all LogicalAnd -- ATTR_GENERIC_ARGS and ATTR_TEMPLATE_FQN are + // attached together by the generic-marker binding (never one without the other), so + // `&&` and `||` select the same branch here. + if (is_array($genericArgs) && is_string($templateFqn)) { + /** @var list $genericArgs */ + $head = ltrim($templateFqn, '\\'); + $useArgs = $genericArgs; + } elseif (is_string($resolvedFqn)) { + $head = ltrim($resolvedFqn, '\\'); + $useArgs = []; + } else { + return null; + } + // 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. If nothing was an + // alias the expansion is identical to the input, so the node is left untouched. + $useRef = new TypeRef($head, $useArgs); + $expanded = $this->expandAlias($useRef, [], $node->getStartLine()); + if ($expanded->canonical() === $useRef->canonical()) { + return null; + } + // Drop the pre-expansion xphp attributes; typeRefToNode re-adds the right ones for + // the expanded head (position attributes are preserved so diagnostics still map back). + $attrs = $node->getAttributes(); + unset( + $attrs[XphpSourceParser::ATTR_GENERIC_ARGS], + $attrs[XphpSourceParser::ATTR_TEMPLATE_FQN], + $attrs[XphpSourceParser::ATTR_RESOLVED_FQN], + $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], + ); + return Specializer::typeRefToNode($expanded, $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 + * body (params → arguments) and re-expanded, so nested and concrete-instantiation aliases + * (`type UserMap = Pair`) resolve fully. A head that recurs into itself is a + * cycle, and a use whose argument count differs from the alias's parameter count is an + * arity error — both fail loudly (refined into `xphp.alias_cycle` / `xphp.alias_arity` + * diagnostics in a later change). + * + * @param list $visited alias FQNs already entered on this expansion chain + */ + private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef + { + $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $line), $ref->args); + $entry = $this->aliasTable[$ref->name] ?? null; + if ($entry === null) { + return new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared); + } + if (in_array($ref->name, $visited, true)) { + throw new XphpParseException( + "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", + $line, + ); + } + if (count($expandedArgs) !== count($entry['paramNames'])) { + throw new XphpParseException( + "Type alias `{$ref->name}` expects " . count($entry['paramNames']) + . ' type argument(s), ' . count($expandedArgs) . ' given.', + $line, + ); + } + $subst = []; + foreach ($entry['paramNames'] as $k => $paramName) { + $subst[$paramName] = $expandedArgs[$k]; + } + $substituted = self::substituteTypeRef($this->resolveAliasBody($ref->name, $entry), $subst); + return $this->expandAlias($substituted, [...$visited, $ref->name], $line); + } + + /** + * Resolve an alias's raw body against the current namespace context, with the alias's own + * type parameters pushed so `A` / `B` become type-param references rather than qualified + * class names. Cached per alias FQN. + * + * @param array{paramNames:list, body:TypeRef} $entry + */ + private function resolveAliasBody(string $fqn, array $entry): TypeRef + { + // @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 the body 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[] = $entry['paramNames']; + $resolved = $this->resolveTypeRef($entry['body']); + $this->typeParamStack = $saved; + return $this->aliasBodyCache[$fqn] = $resolved; + } + + /** + * Replace type-parameter leaves in a resolved TypeRef tree using a name → concrete map. + * + * @param array $subst + */ + private static function substituteTypeRef(TypeRef $ref, array $subst): TypeRef + { + // @infection-ignore-all LogicalAnd -- a resolved body's type-param leaves are exactly + // the alias's parameters, every one present in $subst; and a class leaf's FQN name + // never equals a bare parameter-name key. So both operands are always true together or + // false together, and `&&`/`||` select the same result. + if ($ref->isTypeParam && isset($subst[$ref->name])) { + return $subst[$ref->name]; + } + return new TypeRef( + $ref->name, + array_map(static fn (TypeRef $a): TypeRef => self::substituteTypeRef($a, $subst), $ref->args), + $ref->isScalar, + $ref->isTypeParam, + $ref->suspectUndeclared, + ); + } + /** * A bare, single-segment, non-imported class name used inside a generic * context — the suspect condition shared by the bound/default TypeRef path diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php new file mode 100644 index 00000000..a0958415 --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -0,0 +1,281 @@ +] = SingleHead;`, WI-01): a declared alias + * is a compile-time substitution — it is expanded into its body before specialization and has no + * runtime existence. Covers a generic alias, a non-generic (plain-class) alias, and a + * concrete-instantiation alias that references another alias; that the emitted program runs; that the + * alias name is absent from the output; and that a cyclic or arity-mismatched alias fails loudly. + */ +final class TypeAliasIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-alias-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + #[RunInSeparateProcess] + public function testTypeAliasesExpandAndRunAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. That the program runs and returns the + // right classes proves each alias expanded to its body and dispatched to real specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/type_aliases/source', + 'aliases', + ); + try { + $fixture->registerAutoload('App\\Aliases'); + $runtime = require __DIR__ . '/../../fixture/compile/type_aliases/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testGenericAliasExpandsToItsBodySpecialization(): void + { + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict>;\nfunction f(): Pair { return new Pair::(1, new Bag::(new User())); }\n", + ]), 'Use.php'); + + // Pair → Dict>: the emitted type is the Dict specialization… + self::assertStringContainsString('Generated\\App\\Dict\\T_', $use); + // …and the alias name is gone entirely (no `Pair`, no residual turbofish). + self::assertStringNotContainsString('Pair', $use); + self::assertStringNotContainsString('::<', $use); + } + + public function testNonGenericAliasExpandsToItsTargetClass(): void + { + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict>;\ntype UserMap = Pair;\nfunction f(): UserMap { return new UserMap(1, new Bag::(new User())); }\n", + ]), 'Use.php'); + + // UserMap → Pair → Dict>: fully expanded, no alias name remains. + self::assertStringContainsString('Generated\\App\\Dict\\T_', $use); + self::assertStringNotContainsString('UserMap', $use); + self::assertStringNotContainsString('Pair', $use); + } + + public function testAliasInGenericArgumentPositionExpands(): void + { + // An alias used as a generic ARGUMENT of a non-alias type (`Bag`) must expand too — + // expansion recurses into arguments, not just the head. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { return new Bag::(new User()); }\n", + ]), 'Use.php'); + + // Bag → Bag: the Bag specialization holds User; no `Elem` remains. + self::assertStringContainsString('Generated\\App\\Bag\\T_', $use); + self::assertStringNotContainsString('Elem', $use); + } + + public function testNonAliasTypeInAnAliasFileIsLeftUnchanged(): void + { + // The alias table is consulted for every type-position name, but a non-alias class type must + // pass through byte-for-byte — expansion rebuilds a node only when something actually changed. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { return new Bag::(\$u); }\n", + ]), 'Use.php'); + + // `User` (a real class, not an alias) is emitted exactly as written — not rewritten/qualified. + self::assertStringContainsString('function k(User $u)', $use); + } + + public function testAliasIsKeyedByItsDeclaringNamespace(): void + { + // An alias declared in the SECOND namespace must key under that namespace — the byte-span + // attribution must not fall through to an earlier namespace (the `&&` containment check). + $out = self::read($this->compile([ + 'Multi.xphp' => "` is resolved first, then `Second = A` must resolve `A` + // to the class \App\A, not to a leaked type parameter. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Bag;\ntype Second = A;\nfunction useFirst(): First { return new Bag::(1); }\nfunction useSecond(): Second { return new A(); }\n", + ]), 'Use.php'); + + // Second → A resolves to the class \App\A (a leaked type param would emit a bare `\A`). + self::assertStringContainsString('App\\A', $use); + } + + public function testAliasInGlobalNamespaceBlock(): void + { + // A `namespace { ... }` block has no name; the alias keys under the global namespace. + $out = self::read($this->compile([ + 'G.xphp' => " " = B;\ntype B = A;\nclass Box { public function __construct(public T \$v) {} }\nfunction f(): A { return new Box::(1); }\n", + ]; + self::assertRejected($this->check($files), 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + + public function testAliasArityMismatchIsRejectedInBothModes(): void + { + $files = [ + 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", + ]; + self::assertRejected($this->check($files), 'expects 2 type argument(s), 1 given'); + $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); + } + + private static function assertRejected(DiagnosticCollector $collector, string $needle): void + { + self::assertTrue($collector->hasErrors(), 'check must collect the alias rejection, not silently pass'); + $messages = array_map(static fn ($d): string => $d->message, $collector->all()); + self::assertStringContainsString($needle, implode("\n", $messages)); + } + + /** @param array $files */ + private function assertCompileThrows(array $files, string $needle): void + { + try { + $this->compile($files); + self::fail('compile must reject the alias loudly'); + } catch (XphpParseException $e) { + self::assertStringContainsString($needle, $e->getMessage()); + } + } + + private const LIB = <<<'PHP' + { public function __construct(public T $item) {} public function get(): T { return $this->item; } } + class Dict { public function __construct(public K $key, public V $value) {} public function value(): V { return $this->value; } } + PHP; + + // --- helpers (kept local, matching the other Monomorphize integration tests) --------------- + + /** @param array $files */ + private function compile(array $files): string + { + $src = $this->writeSources($files); + $dist = $src . '/dist'; + $this->newCompiler()->compile($this->sourcesIn($src), $src, $dist, $src . '/.xphp-cache'); + return $dist; + } + + /** @param array $files */ + private function check(array $files): DiagnosticCollector + { + $src = $this->writeSources($files); + return $this->newCompiler()->check($this->sourcesIn($src)); + } + + /** @param array $files */ + private function writeSources(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + return $src; + } + + private function sourcesIn(string $src): \XPHP\FileSystem\FilepathArray + { + return (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function newCompiler(): Compiler + { + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/fixture/compile/type_aliases/source/Types.xphp b/test/fixture/compile/type_aliases/source/Types.xphp new file mode 100644 index 00000000..bc73db48 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -0,0 +1,51 @@ + = Dict>; +type UserId = Ident; +type UserMap = Pair; +type Elem = User; // used only as a generic ARGUMENT (`Bag`) + +class Ident {} +class User {} + +class Bag +{ + public function __construct(public T $item) {} + public function get(): T { return $this->item; } +} + +class Dict +{ + public function __construct(public K $key, public V $value) {} + public function value(): V { return $this->value; } +} + +class Service +{ + // Alias uses in return-type position (generic + non-generic) must expand before specialization. + public function pair(): Pair + { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId + { + return new UserId(); + } +} + +// Driver: the runtime verify reads these top-level values after requiring the emitted file. +$service = new Service(); +$pair = $service->pair(); +$idValue = $service->id(); +$userMap = new UserMap(2, new Bag::(new User())); +// `Bag` — an alias in generic-argument position. If it did not expand to `Bag`, the +// generated specialization would be typed on the nonexistent class `App\Aliases\Elem` and fatal here. +$elemBag = new Bag::(new User()); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php new file mode 100644 index 00000000..acd1106f --- /dev/null +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -0,0 +1,38 @@ + = Dict>`), a non-generic + * plain-class alias (`UserId = Ident`), and a concrete-instantiation alias that references another + * alias (`UserMap = Pair`) are all erased before specialization, and the emitted program + * executes end to end — the alias uses dispatch to the same specializations the hand-expanded types + * would, and the plain alias resolves to its target class. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated specializations autoload. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Types.php'; + + // Pair === Dict>: the value is a Bag specialization holding a User. + Assert::assertInstanceOf('App\\Aliases\\User', $pair->value()->get(), 'Pair expanded to Dict>'); + + // UserId === Ident (a plain class): the alias resolves to its target class. + Assert::assertInstanceOf('App\\Aliases\\Ident', $idValue, 'UserId expanded to the plain class Ident'); + + // UserMap === Pair === Dict> (nested alias): same shape as $pair. + Assert::assertInstanceOf('App\\Aliases\\User', $userMap->value()->get(), 'UserMap expanded through Pair to Dict>'); + Assert::assertSame( + $pair::class, + $userMap::class, + 'UserMap and Pair expand to the identical specialization', + ); + + // Bag === Bag: an alias in generic-argument position expanded; the item is a User. + Assert::assertInstanceOf('App\\Aliases\\User', $elemBag->get(), 'Bag expanded to Bag'); +}; From 585d933e27b12e46fc79641543258192bde07f25 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 17:38:57 +0000 Subject: [PATCH 03/17] feat(monomorphize): dedicated diagnostics for type-alias rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each type-alias rejection a stable code and raise the two that were previously silent or unclear: - xphp.alias_class_collision — an alias whose FQN matches a class / interface / trait declared in the same file is now a loud error, not a silent shadow of that class. - xphp.alias_unsupported_body — a union / intersection / nullable / closure body is recognized and stripped (so `strip()` never emits a raw PHP parse error) and rejected with a clear message at parse time. - xphp.alias_duplicate — the same alias FQN declared twice in a file. - xphp.alias_cycle / xphp.alias_arity — promoted from the generic parse-error code to dedicated codes. XphpParseException carries an optional diagnostic code; check maps it onto the collected diagnostic (compile still throws). Every rejection is verified in both modes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 5 +- .../Monomorphize/XphpParseException.php | 17 ++- .../Monomorphize/XphpSourceParser.php | 122 +++++++++++++++--- .../Monomorphize/TypeAliasIntegrationTest.php | 65 +++++++++- .../Monomorphize/XphpSourceParserTest.php | 20 +-- 5 files changed, 196 insertions(+), 33 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 23e8e60f..79a63b56 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -478,11 +478,12 @@ public function check(FilepathArray $sources): DiagnosticCollector } catch (XphpParseException $e) { // xphp-specific parse-time rejections from the scanner (e.g. variance markers // on methods, malformed generic defaults) — these carry the offending token's - // original-source line so the diagnostic points at the real site. + // original-source line so the diagnostic points at the real site, and optionally a + // stable diagnostic code (e.g. a type-alias rejection) in place of the generic one. $line = $e->sourceLine(); $diagnostics->add(new Diagnostic( Severity::Error, - self::CODE_PARSE_ERROR, + $e->diagnosticCode() ?? self::CODE_PARSE_ERROR, $e->getMessage(), // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- every // current throw site supplies a real token line (>= 1), so this `> 0` guard is diff --git a/src/Transpiler/Monomorphize/XphpParseException.php b/src/Transpiler/Monomorphize/XphpParseException.php index e22152ea..b743d0fe 100644 --- a/src/Transpiler/Monomorphize/XphpParseException.php +++ b/src/Transpiler/Monomorphize/XphpParseException.php @@ -14,11 +14,18 @@ * `RuntimeException` keep catching it unchanged — only the line is added. Check * mode catches it specifically to report the real line in its diagnostic instead * of the line-1 fallback used for position-less parse failures. + * + * An optional stable diagnostic `code` (e.g. `xphp.alias_cycle`) lets check mode + * report a specific code instead of the generic parse-error code; throw sites that + * omit it keep the generic code. */ final class XphpParseException extends RuntimeException { - public function __construct(string $message, private readonly int $sourceLine) - { + public function __construct( + string $message, + private readonly int $sourceLine, + private readonly ?string $diagnosticCode = null, + ) { parent::__construct($message); } @@ -30,4 +37,10 @@ public function sourceLine(): int { return $this->sourceLine; } + + /** The stable diagnostic code for this rejection, or null to use the generic parse-error code. */ + public function diagnosticCode(): ?string + { + return $this->diagnosticCode; + } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 81cc5ef1..92239d5a 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -110,6 +110,13 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + /** Stable diagnostic codes for type-alias (WI-01) rejections. */ + public const CODE_ALIAS_CYCLE = 'xphp.alias_cycle'; + public const CODE_ALIAS_ARITY = 'xphp.alias_arity'; + public const CODE_ALIAS_DUPLICATE = 'xphp.alias_duplicate'; + public const CODE_ALIAS_CLASS_COLLISION = 'xphp.alias_class_collision'; + public const CODE_ALIAS_UNSUPPORTED_BODY = 'xphp.alias_unsupported_body'; + /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is * unambiguously a builtin, so every site that asks "is this name a builtin keyword or a class?" @@ -307,7 +314,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:TypeRef, bytePosition:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?TypeRef, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -321,7 +328,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:TypeRef, bytePosition:int}> $aliasMarkers */ + /** @var list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2427,7 +2434,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, paramNames:list, body:TypeRef, bytePosition:int}, 1: int}|null + * @return array{0: array{name:string, paramNames:list, body:?TypeRef, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2483,20 +2490,24 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - // Single (possibly-generic) head body. A union / intersection / nullable / closure body - // leaves a non-`;` token after the head and is declined for v1 (a later change turns that - // into an explicit `xphp.alias_unsupported_body` diagnostic rather than a silent decline). - $bodyParsed = self::parseTypeArg($tokens, self::skipWs($tokens, $eqIdx + 1)); - if ($bodyParsed === null) { + // The alias statement must be terminated by a `;` before any `{` / `}` / end of input, + // otherwise it is truncated (mid-typing) and we decline so the tolerant path and PHP's own + // parser handle it. + $bodyStart = self::skipWs($tokens, $eqIdx + 1); + $semiIdx = self::aliasTerminator($tokens, $bodyStart); + if ($semiIdx === null) { return null; } - [$body, $afterBody] = $bodyParsed; - // Terminating `;` (a single-head body leaves it immediately after the head). - $semiIdx = self::skipWs($tokens, $afterBody); - if (($tokens[$semiIdx] ?? null)?->text !== ';') { - return null; - } + // A single (possibly-generic) head immediately followed by that `;` is a supported body. + // Anything else (union / intersection / nullable / closure) is recorded with a null body: + // the whole statement is still stripped here (so `strip()` never produces a PHP parse error), + // and `buildAliasTable` rejects the null body with a clear `xphp.alias_unsupported_body` + // diagnostic at parse time. + $bodyParsed = self::parseTypeArg($tokens, $bodyStart); + $body = ($bodyParsed !== null && self::skipWs($tokens, $bodyParsed[1]) === $semiIdx) + ? $bodyParsed[0] + : null; return [ [ @@ -2504,11 +2515,33 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? 'paramNames' => $paramNames, 'body' => $body, 'bytePosition' => $tokens[$typeIdx]->pos, + 'line' => $tokens[$typeIdx]->line, ], $semiIdx, ]; } + /** + * The index of the `;` that terminates an alias statement whose body starts at $bodyStart, or + * null when a `{` / `}` / end of input is reached first (a truncated, mid-typing declaration). + * A type body never contains `;` / `{` / `}`, so the first such token decides. + * + * @param list $tokens + */ + private static function aliasTerminator(array $tokens, int $bodyStart): ?int + { + for ($i = $bodyStart, $n = count($tokens); $i < $n; $i++) { + $text = $tokens[$i]->text; + if ($text === ';') { + return $i; + } + if ($text === '{' || $text === '}') { + return null; + } + } + return null; + } + /** * Parse a single type arg: `NAME ( < TypeArgList > )?`. * @@ -2809,7 +2842,7 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers * @return array, body:TypeRef}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array @@ -2831,6 +2864,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff ]; } } + $classFqns = self::collectClassLikeFqns($ast); $table = []; foreach ($aliasMarkers as $marker) { $namespace = ''; @@ -2847,11 +2881,65 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff } } $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; + if ($marker['body'] === null) { + throw new XphpParseException( + "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " + . 'or generic type (unions, intersections, nullables, and closure signatures are ' + . 'not supported). Use a bare type or a named class.', + $marker['line'], + self::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } + if (isset($table[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is declared more than once in this file.", + $marker['line'], + self::CODE_ALIAS_DUPLICATE, + ); + } + if (isset($classFqns[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` collides with a class, interface, or trait of the same name.", + $marker['line'], + self::CODE_ALIAS_CLASS_COLLISION, + ); + } $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; } return $table; } + /** + * Collect the fully-qualified names of every class / interface / trait / enum declared in the + * file, so a type alias colliding with one can be rejected. Declarations are direct children of a + * namespace (or top-level in the global namespace); this matches the file-local (v1) scope — a + * collision with a class declared in another file is not detected here. + * + * @param list $ast + * @return array + */ + private static function collectClassLikeFqns(array $ast): array + { + $fqns = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $ns = $stmt->name?->toString() ?? ''; + foreach ($stmt->stmts as $inner) { + if ($inner instanceof ClassLike && $inner->name !== null) { + $short = $inner->name->toString(); + // @infection-ignore-all TrueValue -- a set membership; the value is only ever + // probed with isset(), which is true for any present key (incl. false). + $fqns[$ns === '' ? $short : $ns . '\\' . $short] = true; + } + } + } elseif ($stmt instanceof ClassLike && $stmt->name !== null) { + // @infection-ignore-all TrueValue -- set membership probed only with isset() (above). + $fqns[$stmt->name->toString()] = true; + } + } + return $fqns; + } + /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * @@ -2872,7 +2960,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { @@ -3910,6 +3998,7 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef throw new XphpParseException( "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", $line, + XphpSourceParser::CODE_ALIAS_CYCLE, ); } if (count($expandedArgs) !== count($entry['paramNames'])) { @@ -3917,6 +4006,7 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef "Type alias `{$ref->name}` expects " . count($entry['paramNames']) . ' type argument(s), ' . count($expandedArgs) . ' given.', $line, + XphpSourceParser::CODE_ALIAS_ARITY, ); } $subst = []; diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index a0958415..638913f4 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -168,7 +168,7 @@ public function testCyclicAliasIsRejectedInBothModes(): void $files = [ 'C.xphp' => " = B;\ntype B = A;\nclass Box { public function __construct(public T \$v) {} }\nfunction f(): A { return new Box::(1); }\n", ]; - self::assertRejected($this->check($files), 'in terms of itself'); + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); $this->assertCompileThrows($files, 'in terms of itself'); } @@ -177,13 +177,72 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void $files = [ 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", ]; - self::assertRejected($this->check($files), 'expects 2 type argument(s), 1 given'); + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_ARITY, 'expects 2 type argument(s), 1 given'); $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); } - private static function assertRejected(DiagnosticCollector $collector, string $needle): void + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void + { + // A union / nullable / intersection / closure body is recognized (stripped) but rejected with + // a clear diagnostic — not a raw PHP parse error. The full message is asserted so a reworded + // or truncated diagnostic is caught. + $files = [ + 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); + $this->assertCompileThrows($files, $message); + } + + public function testNoSpaceAliasBodyExpands(): void + { + // `type Id=Ident;` (no spaces around `=`) is a valid single-head alias, not an unsupported + // body — the body-start scan must land on `Ident`, not the `;`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " "check($namespaced), XphpSourceParser::CODE_ALIAS_CLASS_COLLISION, 'collides with a class'); + $this->assertCompileThrows($namespaced, 'collides with a class'); + + // Global namespace (no `namespace` statement): the top-level class-declaration branch. + $global = [ + 'G.xphp' => "check($global), XphpSourceParser::CODE_ALIAS_CLASS_COLLISION, 'collides with a class'); + $this->assertCompileThrows($global, 'collides with a class'); + } + + public function testDuplicateAliasIsRejectedInBothModes(): void + { + $files = [ + 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + $this->assertCompileThrows($files, 'declared more than once'); + } + + private static function assertRejected(DiagnosticCollector $collector, string $code, string $needle): void { self::assertTrue($collector->hasErrors(), 'check must collect the alias rejection, not silently pass'); + $codes = array_map(static fn ($d): string => $d->code, $collector->all()); + self::assertContains($code, $codes, 'check must report the dedicated alias diagnostic code'); $messages = array_map(static fn ($d): string => $d->message, $collector->all()); self::assertStringContainsString($needle, implode("\n", $messages)); } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index a0ed6357..dd919498 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -186,22 +186,22 @@ public function testTypeOutsideStatementPositionIsNeverAnAliasDeclaration(): voi } /** - * v1 recognizes only a single (possibly-generic) head body. A union / intersection / nullable - * body is declined at scan (left intact), to become an explicit diagnostic in a later change — - * it must never be half-stripped. + * A union / nullable body is a RECOGNIZED (but unsupported) alias: the whole statement is + * stripped at scan (so `strip()` never produces a raw PHP parse error), and the + * `xphp.alias_unsupported_body` diagnostic is raised later at parse time (see the integration + * test). A reserved-word head is not a recognized alias at all and is left byte-for-byte intact. */ - public function testNonSingleHeadAliasBodiesAreDeclined(): void + public function testUnsupportedAliasBodyIsStrippedWhileReservedNameIsDeclined(): void { $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - // Union / nullable bodies: parseTypeArg stops at `|` / returns null on `?`, leaving a - // non-`;` token after the head, so the declaration is declined and left byte-for-byte intact. $union = "strip($union)); + self::assertSame(self::withBlanked($union, 'type Num = int|float;'), $parser->strip($union)); $nullable = "strip($nullable)); - // The alias name must be a real identifier (T_STRING). A reserved word like `array` - // (T_ARRAY) is not a valid alias head, so the declaration is declined and left intact. + self::assertSame(self::withBlanked($nullable, 'type Maybe = ?Box;'), $parser->strip($nullable)); + + // A reserved word (`array`, T_ARRAY) is not a valid alias head, so the declaration is not + // recognized and is left byte-for-byte intact. $reserved = "strip($reserved)); } From 729e16c432dd367dffba24c7eccd70ed125dee71 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 17:49:23 +0000 Subject: [PATCH 04/17] docs(type-aliases): document the feature, ADR-0023, roadmap, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New syntax tour page (docs/syntax/type-aliases.md) + index row. - Caveat covering the v1 boundaries (file-local, single-head bodies, same-file collision detection) and their reasons. - Roadmap: move type aliases from Discovery to Shipped. - ADR-0023: the declaration-form syntax decision (`type Name<…> = Body`) and compile-time-substitution model, with the alternatives weighed. - CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++ .../adr/0023-type-alias-declaration-syntax.md | 113 ++++++++++++++++++ docs/adr/README.md | 1 + docs/caveats.md | 48 ++++++++ docs/roadmap.md | 19 ++- docs/syntax/index.md | 1 + docs/syntax/type-aliases.md | 105 ++++++++++++++++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0023-type-alias-declaration-syntax.md create mode 100644 docs/syntax/type-aliases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d67b20..8546cb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Type aliases.** `type Name = Body;` (generic) and `type Name = Body;` + (non-generic) give a type a reusable name. An alias is a compile-time + substitution — expanded into its body before specialization, with no runtime + existence, so the emitted PHP never mentions the alias. It expands in every type + position, including as a generic argument (`Bag`), and composes with + nested and concrete-instantiation aliases (`type UserMap = Pair`). + v1 is file-local with single-head bodies; a cyclic (`xphp.alias_cycle`), + arity-mismatched (`xphp.alias_arity`), class-colliding + (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), or + unsupported-body (`xphp.alias_unsupported_body`, e.g. a union/nullable/closure + body) 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 type parameters are determined by the argument values no longer needs the `::<>` turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md new file mode 100644 index 00000000..83879fb7 --- /dev/null +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -0,0 +1,113 @@ +# 23. Type-alias syntax is the declaration form `type Name<…> = Body` + +- Status: Accepted — 2026-07 + +## Context and Problem Statement + +xphp adds type aliases — a name for a type, expanded at compile time (see +[type aliases](../syntax/type-aliases.md)). A first-class goal is that an alias may be +**generic** (`type Pair = Map>`), not only a name for a fixed type. + +PHP itself has a live but unsettled proposal, [PHP RFC: Type +Aliases](https://wiki.php.net/rfc/typed-aliases), which uses an *import* form +(`use type int|float as Number;`) and explicitly lists parameterized (generic) aliases +under "Future Scope" — so there is no PHP-blessed syntax for the generic case xphp needs. +xphp must therefore choose a surface, ideally one that stays forward-compatible with where +PHP is most likely to land. + +## Decision Drivers + +- **Must express generic aliases**, since that is a primary goal. +- Forward-compatibility with a plausible future PHP syntax. +- Fit xphp's existing angle-bracket surface (`Foo`, the `::<>` turbofish). +- Correctness first: no silent miscompile; an alias must lower to exactly what its body + would have. + +## Considered Options + +- **A — declaration form `type Name<…> = Body;`** (with the non-generic case being the + zero-parameter `type Name = Body;`). The form used by TypeScript, Rust, Scala, and — most + relevantly — **Hack**, PHP's closest relative. +- **B — import form `use type Body as Name;`** (PHP's current RFC). +- **C — a distinct keyword** (`typedef` / `typealias`). +- **D — a runtime, autoloadable alias symbol** (an alias that exists at runtime and via + reflection), rather than a pure compile-time substitution. + +## Decision Outcome + +Chosen: **A — the declaration form `type Name<…> = Body`, resolved as a compile-time +substitution.** + +The import form (B) is eliminated by the generic requirement: `use type Body as Name` has +no place to put parameters on `Name` (`use type Map> as Pair` is +ambiguous), which is almost certainly why PHP deferred generic aliases. The declaration +form is the *only* one of the two that expresses both cases with a single rule, and it is +what every language that supports generic aliases uses. Hack — the closest precedent to +xphp's situation — spells it exactly `type Name = …;`. It also fits xphp's own +angle-bracket surface. A distinct keyword (C) buys nothing over `type` and is further from +that precedent. + +Aliases are a **compile-time substitution** with no runtime existence (not option D). The +long-standing blocker for PHP here — how to autoload/define a runtime alias symbol — simply +does not arise for xphp: it is a whole-program, build-time transpiler +([ADR-0002](0002-build-time-transpiler.md)), so an alias is expanded before specialization +and needs no runtime identity. + +### Consequences + +- Good: one grammar covers generic and non-generic aliases; it matches the cross-language + and Hack consensus and xphp's existing syntax; expansion reuses the monomorphizer with no + new emission path or runtime cost. +- 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: v1 is scoped to file-local, single-head bodies (see the + [caveat](../caveats.md#type-aliases-are-file-local-and-single-head)) — a safe subset, + with cross-file and richer bodies as later work. + +### Confirmation + +The scanner recognizes `type Name[<…>] = SingleHead;` and strips it; expansion is exercised +end to end by `test/fixture/compile/type_aliases/` (a runtime fixture that executes the +compiled output and asserts no alias name survives) and the `TypeAliasIntegrationTest` +cases. Every rejection carries a stable code (`xphp.alias_cycle`, `xphp.alias_arity`, +`xphp.alias_class_collision`, `xphp.alias_duplicate`, `xphp.alias_unsupported_body`) and is +verified in both `compile` and `check`. + +## Pros and Cons of the Options + +### A — declaration form `type Name<…> = Body` + +- Good: expresses generic and non-generic aliases with one rule; matches Hack + TS + Rust + + Scala; fits xphp's angle-bracket surface. +- Bad: leads PHP for the generic case (PHP has only the import form, and only for + non-generic aliases so far). + +### B — import form `use type Body as Name` + +- Good: matches PHP's current RFC for the non-generic case; forward-compatible there. +- Bad: cannot carry type parameters, so it cannot express generic aliases — the primary + goal. + +### C — distinct keyword (`typedef` / `typealias`) + +- Good: unambiguous keyword. +- Bad: no advantage over `type`; further from the Hack precedent and the cross-language norm. + +### D — runtime / autoloadable alias symbol + +- Good: reflection and cross-file use "for free". +- Bad: imports PHP's unsolved autoloading/definition problem for no benefit — xphp expands + aliases at build time and needs no runtime symbol. + +## More Information + +- [Type aliases](../syntax/type-aliases.md) and the + [file-local / single-head caveat](../caveats.md#type-aliases-are-file-local-and-single-head). +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization; + [ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias + symbol is unnecessary). +- [PHP RFC: Type Aliases](https://wiki.php.net/rfc/typed-aliases) (import form; generic + aliases in Future Scope); [PHP RFC: Bound-erased generic + types](https://wiki.php.net/rfc/bound_erased_generic_types) (the `Foo` surface xphp + tracks). Hack spells the declaration form `type Name = …;` (and `newtype`). diff --git a/docs/adr/README.md b/docs/adr/README.md index c0d5bcdb..b4216ff8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,4 @@ should be added here as a new numbered file; copy | [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted | | [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted | | [0022](0022-bounds-are-upper-only.md) | Bounds are upper-only (no supertype/lower bounds) | Accepted | +| [0023](0023-type-alias-declaration-syntax.md) | Type-alias syntax is the declaration form `type Name<…> = Body` | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index a261238e..560b707b 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -89,6 +89,54 @@ wherever inference can't see the type. It's always accepted, and an inferred call is identical to the turbofished one — so adding a turbofish never changes behavior, only makes the type explicit. +## Type aliases are file-local and single-head + +[Type aliases](syntax/type-aliases.md) (`type Name<…> = Body;`) are a compile-time +substitution — a deliberately small first step, with three boundaries. + +### ❌ What doesn't work + +```php +// File Types.xphp +type UserId = Ident; + +// File Other.xphp — a DIFFERENT file +function f(): UserId { /* ... */ } // ✗ UserId is not visible here (file-local) + +type Num = int|string; // ✗ xphp.alias_unsupported_body — union body +type Maybe = ?Box; // ✗ xphp.alias_unsupported_body — nullable body +type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature +``` + +An alias colliding with a class in **another** file is also not detected (a +same-file collision is — `xphp.alias_class_collision`). + +### Why + +An alias is expanded before specialization, during the per-file parse: it has no +runtime existence, and the parse has no cross-file symbol table, so an alias is +scoped to the file (and namespace) that declares it. The body is restricted to a +single class or generic *head* because that is the shape the monomorphizer can +substitute directly into a type position; a union / intersection / nullable / +closure body has no single identity to carry through specialization, so it is +rejected loudly rather than mis-compiled. Both boundaries are the same "make the +safe subset solid first" trade the rest of xphp makes — they are candidates to +lift later, not permanent design limits. + +### ✅ Workaround + +- Keep an alias and its uses in the **same file**. For a shared vocabulary, + declare the alias in each file that needs it (it's a zero-cost substitution). +- For a non-single-head type, write the type directly, or wrap it in a named + class or interface and alias *that*: + +```php +type UserId = int|string; // ✗ rejected +interface UserId { /* marker */ } // ✓ a named type you can alias/reference +``` + +--- + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work diff --git a/docs/roadmap.md b/docs/roadmap.md index b5e32956..6fcd6cbd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,6 +50,9 @@ timeline Reified T : runtime instanceof T : marker interface per template + Type aliases + : compile-time substitution + : file-local single-head bodies Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -61,7 +64,6 @@ timeline : PHPStan over the compiled output section Discovery Generic surface - : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision Generic completeness @@ -219,6 +221,20 @@ upcoming one. - Marker interface per template so `$x instanceof App\Box` works across every `Box<...>` specialization. +### Type aliases + +- `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). +- Expands in every type position, including as a generic argument + (`Bag`); composes with nested and concrete-instantiation + aliases (`type UserMap = Pair`). +- File-local, single-head bodies (v1). Cyclic, arity-mismatched, + class-colliding, duplicate, and unsupported-body aliases 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 + [file-local / single-head caveat](caveats.md#type-aliases-are-file-local-and-single-head). + ### Naming and collisions - SHA-256-based generated FQCN; namespace mirrors the template. @@ -264,7 +280,6 @@ to ship. ### Generic surface -- Generic type aliases (e.g. `type Pair = ...`). - Variance edges on trait-owned templates. - Branching narrowing precision: today a turbofish call on a receiver whose branch arms disagree is a compile error; could track unions with diff --git a/docs/syntax/index.md b/docs/syntax/index.md index cfc65451..a95af6f0 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,6 +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 | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md new file mode 100644 index 00000000..0214ac11 --- /dev/null +++ b/docs/syntax/type-aliases.md @@ -0,0 +1,105 @@ +# Type aliases + +A type alias gives a name to a type — generic or not — so you can write +it once and reuse it. It's a **compile-time substitution**: the alias is +expanded into its body before specialization and has no runtime +existence, so the emitted PHP never mentions the alias name. + +```php +type Pair = Dict>; // generic alias +type UserId = Ident; // non-generic alias (a plain class) +type UserMap = Pair; // a concrete instantiation of another alias +``` + +## Example + +```php + = Dict>; +type UserId = Ident; + +class Service { + public function pair(): Pair { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId { + return new UserId(); + } +} +``` + +## What gets emitted + +Each use is replaced by its expanded body, then monomorphized exactly as +if you had written the body by hand. `Pair` expands to +`Dict>` (a real specialization); `UserId` expands to the +plain class `Ident`. The `type …` declarations themselves vanish. + +```php +namespace App; + +class Service { + public function pair(): \XPHP\Generated\App\Dict\T_ { + return new \XPHP\Generated\App\Dict\T_(1, new \XPHP\Generated\App\Bag\T_(new User())); + } + public function id(): \App\Ident { + return new \App\Ident(); + } +} +``` + +Because expansion happens before specialization, an aliased generic +records and specializes the same class an explicit type would — there is +no separate code path and no runtime cost. + +## Rules + +- **Two forms**: `type Name = Body;` (generic) and + `type Name = Body;` (non-generic). The parameter list is optional; the + separator is `=`. +- The alias expands in **every type position** — parameter, return, + property, `new`, turbofish argument, `extends`/`implements`, and as a + **generic argument** of another type (`Bag`). +- Aliases compose: an alias body may reference another alias + (`type UserMap = Pair`), and an alias may take type + parameters used inside its body (`type Pair = Dict>`). +- A non-alias name of the same shape is untouched — only a declared alias + is expanded. +- The following are compile errors (each with a stable code, reported by + both `xphp compile` and `xphp check`): + - `xphp.alias_cycle` — an alias defined, directly or transitively, in + terms of itself (`type A = B; type B = A;`). + - `xphp.alias_arity` — a use whose type-argument count differs from the + alias's parameter count (`type P = …;` used as `P`). + - `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` — see caveats below. + +## Caveats + +Aliases are intentionally a small, safe first step. See +[caveats → type aliases](../caveats.md#type-aliases-are-file-local-and-single-head) +for the details and the reasons: + +- **File-local.** An alias is usable only within the file that declares + it (and only within its declaring namespace). Cross-file / importable + aliases are not supported yet. +- **Single-head bodies.** The body must be a single class or generic type + (`Dict`, `Ident`, `Bag`). A union, intersection, nullable, or + closure-signature body (`int|string`, `?Box`, `Closure(int): int`) is + rejected with `xphp.alias_unsupported_body` — use a bare type or a named + class. +- **Same-file collision detection.** An alias colliding with a class + declared in *another* file is not detected. + +## See also + +- Test fixture: `test/fixture/compile/type_aliases/` +- Related: [classes and interfaces](classes-and-interfaces.md), + [turbofish](turbofish.md) From 89bab9de5ae19b75b09f317feddba2c68f41df56 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 19:40:09 +0000 Subject: [PATCH 05/17] feat(monomorphize): union and nullable type-alias bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the single-head-only restriction for union (`A|B|…`) and nullable (`?X`) alias bodies, which expand into a real PHP `UnionType` / `NullableType` where a slot can hold one. `?X` desugars to `X|null`; a three-member `A|B|null` stays a `UnionType`, while `?X` (one non-null, atomic) emits `?X` (`?(A&B)` would be a fatal parse error). A compound (union) alias is only representable as the WHOLE type of a param / property / return / class-const slot — threaded via a wholeSlot flag from markType. As a generic argument, in `new` / turbofish / `extends` / a bound, or nested inside another nullable/union at the use site, it is rejected loudly (`xphp.alias_compound_in_non_slot`) in both modes. Intersection, DNF, and closure-signature bodies remain `xphp.alias_unsupported_body` (a later change). A single-head alias that transitively resolves to a union expands as a union too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 233 ++++++++++++++---- .../Monomorphize/TypeAliasIntegrationTest.php | 52 +++- .../Monomorphize/XphpSourceParserTest.php | 12 +- .../compile/type_aliases/source/Types.xphp | 10 + .../compile/type_aliases/verify/runtime.php | 4 + 5 files changed, 258 insertions(+), 53 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 92239d5a..66900aa3 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -110,12 +110,18 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; - /** Stable diagnostic codes for type-alias (WI-01) rejections. */ + // Set on a type-hint Name that is the WHOLE type of a param / property / return / class-const + // slot (not nested inside a nullable/union/intersection). A compound-body alias may only expand + // here — elsewhere it has no representable form and is rejected. + public const ATTR_ALIAS_WHOLE_SLOT = 'xphp:aliasWholeSlot'; + + /** Stable diagnostic codes for type-alias rejections. */ public const CODE_ALIAS_CYCLE = 'xphp.alias_cycle'; public const CODE_ALIAS_ARITY = 'xphp.alias_arity'; public const CODE_ALIAS_DUPLICATE = 'xphp.alias_duplicate'; 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'; /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is @@ -314,7 +320,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?TypeRef, bytePosition:int, line:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -328,7 +334,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2434,7 +2440,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, paramNames:list, body:?TypeRef, bytePosition:int, line:int}, 1: int}|null + * @return array{0: array{name:string, paramNames:list, body:?list, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2499,15 +2505,11 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - // A single (possibly-generic) head immediately followed by that `;` is a supported body. - // Anything else (union / intersection / nullable / closure) is recorded with a null body: - // the whole statement is still stripped here (so `strip()` never produces a PHP parse error), - // and `buildAliasTable` rejects the null body with a clear `xphp.alias_unsupported_body` - // diagnostic at parse time. - $bodyParsed = self::parseTypeArg($tokens, $bodyStart); - $body = ($bodyParsed !== null && self::skipWs($tokens, $bodyParsed[1]) === $semiIdx) - ? $bodyParsed[0] - : 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); return [ [ @@ -2521,6 +2523,54 @@ 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`. + * + * @param list $tokens + * @return list|null + */ + 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. + // @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 [$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 = []; + $i = $bodyStart; + while (true) { + $parsed = self::parseTypeArg($tokens, $i); + if ($parsed === null) { + return null; + } + $members[] = $parsed[0]; + $next = self::skipWs($tokens, $parsed[1]); + if ($next === $semiIdx) { + return $members; + } + // @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 null; + } + $i = self::skipWs($tokens, $next + 1); + } + } + /** * The index of the `;` that terminates an alias statement whose body starts at $bodyStart, or * null when a `{` / `}` / end of input is reached first (a truncated, mid-typing declaration). @@ -2842,8 +2892,8 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers - * @return array, body:TypeRef}> + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:list}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -2960,7 +3010,7 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { @@ -2985,7 +3035,7 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:TypeRef}> $aliasTable file-local + * @param array, body:list}> $aliasTable file-local * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( @@ -3003,7 +3053,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 = []; @@ -3354,7 +3404,9 @@ public function enterNode(Node $node): ?Node // Use_ branches (so $ctx is populated) and after the ClassLike/ // method type-param push (so isEnclosingTypeParam sees this scope). if ($node instanceof Node\Stmt\Class_) { - $this->markType($node->extends); + // `extends` needs a single class, so a compound alias there must reject — + // wholeSlot:false (a single-head alias still expands regardless of the flag). + $this->markType($node->extends, false); foreach ($node->implements as $impl) { $this->markName($impl); } @@ -3380,17 +3432,17 @@ public function enterNode(Node $node): ?Node $this->markName($type); } } elseif ($node instanceof Node\Param) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\Property) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassConst) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction ) { - $this->markType($node->returnType); + $this->markType($node->returnType, true); } return null; @@ -3401,16 +3453,22 @@ public function enterNode(Node $node): ?Node * through nullable/union/intersection wrappers). Scalar `Identifier` * leaves and non-Name expressions are left untouched. */ - private function markType(?Node $type): void + private function markType(?Node $type, bool $wholeSlot): void { if ($type instanceof Name) { $this->attachClosureSig($type); $this->markName($type); + // A Name that IS the whole slot type may expand to a compound (union) alias; a Name + // reached through the nullable/union/intersection recursion below is nested and may + // not (tagged only at the top level). + if ($wholeSlot) { + $type->setAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT, true); + } } elseif ($type instanceof Node\NullableType) { - $this->markType($type->type); + $this->markType($type->type, false); } elseif ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { foreach ($type->types as $inner) { - $this->markType($inner); + $this->markType($inner, false); } } } @@ -3957,23 +4015,75 @@ private function expandAliasName(Name $node): ?Node return null; } // 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. If nothing was an - // alias the expansion is identical to the input, so the node is left untouched. + // 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`). $useRef = new TypeRef($head, $useArgs); - $expanded = $this->expandAlias($useRef, [], $node->getStartLine()); - if ($expanded->canonical() === $useRef->canonical()) { - return null; - } - // Drop the pre-expansion xphp attributes; typeRefToNode re-adds the right ones for - // the expanded head (position attributes are preserved so diagnostics still map back). + $members = $this->expandAliasToUnion($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(); unset( $attrs[XphpSourceParser::ATTR_GENERIC_ARGS], $attrs[XphpSourceParser::ATTR_TEMPLATE_FQN], $attrs[XphpSourceParser::ATTR_RESOLVED_FQN], $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], + $attrs[XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT], ); - return Specializer::typeRefToNode($expanded, $attrs); + if (count($members) === 1) { + if ($members[0]->canonical() === $useRef->canonical()) { + return null; + } + return Specializer::typeRefToNode($members[0], $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 " + . 'of a parameter, property, return, or class-constant slot.', + $node->getStartLine(), + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return self::unionMembersToNode($members, $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. + * + * @param list $members + * @param array $attrs + */ + private static function unionMembersToNode(array $members, array $attrs): Node + { + $hasNull = false; + /** @var list $nonNull */ + $nonNull = []; + foreach ($members as $m) { + // @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') { + $hasNull = true; + } else { + $nonNull[] = $m; + } + } + // 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) { + $nodes[] = new Node\Identifier('null'); + } + return new Node\UnionType($nodes, $attrs); } /** @@ -3988,11 +4098,39 @@ private function expandAliasName(Name $node): ?Node * @param list $visited alias FQNs already entered on this expansion chain */ 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 + // 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 of a parameter, property, return, or class-constant slot.', + $line, + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return $members[0]; + } + + /** + * 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. + * + * @param list $visited alias FQNs already entered on this expansion chain + * @return list + */ + private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array { $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $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 TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]; } if (in_array($ref->name, $visited, true)) { throw new XphpParseException( @@ -4013,8 +4151,14 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef foreach ($entry['paramNames'] as $k => $paramName) { $subst[$paramName] = $expandedArgs[$k]; } - $substituted = self::substituteTypeRef($this->resolveAliasBody($ref->name, $entry), $subst); - return $this->expandAlias($substituted, [...$visited, $ref->name], $line); + $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; + } + } + return $members; } /** @@ -4022,21 +4166,22 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef * type parameters pushed so `A` / `B` become type-param references rather than qualified * class names. Cached per alias FQN. * - * @param array{paramNames:list, body:TypeRef} $entry + * @param array{paramNames:list, body:list} $entry + * @return list */ - private function resolveAliasBody(string $fqn, array $entry): TypeRef + private function resolveAliasBody(string $fqn, array $entry): array { // @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 the body 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 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. $saved = $this->typeParamStack; $this->typeParamStack[] = $entry['paramNames']; - $resolved = $this->resolveTypeRef($entry['body']); + $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 638913f4..4106e903 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -161,6 +161,50 @@ public function testAliasInGlobalNamespaceBlock(): void self::assertStringContainsString('Ident', $out); } + public function testUnionAndNullableBodiesExpandInWholeSlots(): void + { + // A union body expands into a param/property/return/class-const slot as a real `int|string`; + // a nullable body as `?\App\Ident`; a three-member union incl. null stays a `UnionType` (not + // `?int`); and a single-head alias transitively resolving to a union expands too. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " "class Bag {}\ntype Num = int|string;\nfunction f(): Bag { return new Bag::(); }", + 'new' => "type Num = int|string;\nfunction f(): int { \$x = new Num(); return 1; }", + '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; }", + ] as $body) { + $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, $needle); + $this->assertCompileThrows($files, $needle); + } + } + + public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void + { + // `?A|B` is illegal PHP (`?` cannot precede a union); the body is declined (not mis-read as + // `?A`), so the declaration is an unsupported-body error rather than a wrong acceptance. + $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, 'unsupported body'); + $this->assertCompileThrows($files, 'unsupported body'); + } + public function testCyclicAliasIsRejectedInBothModes(): void { // A directly-or-transitively self-referential alias would expand without bound; it is @@ -183,11 +227,11 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { - // A union / nullable / intersection / closure body is recognized (stripped) but rejected with - // a clear diagnostic — not a raw PHP parse error. The full message is asserted so a reworded - // or truncated diagnostic is caught. + // 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. $files = [ - 'C.xphp' => " "createForHostVersion()); @@ -199,6 +199,8 @@ public function testUnsupportedAliasBodyIsStrippedWhileReservedNameIsDeclined(): self::assertSame(self::withBlanked($union, 'type Num = int|float;'), $parser->strip($union)); $nullable = "strip($nullable)); + $intersection = "strip($intersection)); // A reserved word (`array`, T_ARRAY) is not a valid alias head, so the declaration is not // recognized and is left byte-for-byte intact. diff --git a/test/fixture/compile/type_aliases/source/Types.xphp b/test/fixture/compile/type_aliases/source/Types.xphp index bc73db48..af5ae7d6 100644 --- a/test/fixture/compile/type_aliases/source/Types.xphp +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -11,6 +11,9 @@ type Pair = Dict>; type UserId = Ident; type UserMap = Pair; type Elem = User; // used only as a generic ARGUMENT (`Bag`) +type Num = int|string; // union body — expands into a whole slot as `int|string` +type MaybeUser = ?User; // nullable body — expands as `?User` +type Aliased = Num; // single head that transitively resolves to a union class Ident {} class User {} @@ -39,6 +42,10 @@ class Service { return new UserId(); } + + // Union / nullable / transitively-union alias uses in whole-slot positions. + public function num(Num $x): Aliased { return $x; } + public function maybe(): MaybeUser { return null; } } // Driver: the runtime verify reads these top-level values after requiring the emitted file. @@ -49,3 +56,6 @@ $userMap = new UserMap(2, new Bag::(new User())); // `Bag` — an alias in generic-argument position. If it did not expand to `Bag`, the // generated specialization would be typed on the nonexistent class `App\Aliases\Elem` and fatal here. $elemBag = new Bag::(new User()); +// Union / nullable slot expansion: if `Num` did not become `int|string`, passing a string would fatal. +$numValue = $service->num('hi'); +$maybeValue = $service->maybe(); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index acd1106f..85d16007 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -35,4 +35,8 @@ // Bag === Bag: an alias in generic-argument position expanded; the item is a User. Assert::assertInstanceOf('App\\Aliases\\User', $elemBag->get(), 'Bag expanded to Bag'); + + // Union / nullable slots executed: `num('hi')` typed `int|string`, `maybe()` typed `?User`. + Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); + Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); }; From 396b7e0b63e9e3735ffeb204801a5900cb66201a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 20:19:41 +0000 Subject: [PATCH 06/17] feat(monomorphize): whole-program (cross-file) type aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make an alias declared in one file usable in another. The Compiler runs a pre-pass over every source, merging each file's local alias table (XphpSourceParser::aliasTableOf) into one whole-program table, then injects it into the per-file parse so expansion resolves an alias no matter which file declares it. A file whose own aliases are malformed is skipped in the pre-pass; the same rejection re-surfaces (and is collected in check mode) when that file is parsed for real. A standalone parse (the LSP / tolerant path) keeps aliases file-local — the whole-program table is a compile/check concern. Same-file duplicate / collision / unsupported-body checks are unchanged; cross-file duplicate and collision are last-wins / undetected (a later refinement). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 53 ++++++++++++++++--- .../Monomorphize/XphpSourceParser.php | 46 +++++++++++++--- .../Monomorphize/TypeAliasIntegrationTest.php | 36 +++++++++++++ .../compile/type_aliases/source/Consumer.xphp | 16 ++++++ .../compile/type_aliases/verify/runtime.php | 4 ++ 5 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 test/fixture/compile/type_aliases/source/Consumer.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 79a63b56..466d5f0f 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -455,13 +455,18 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); - $astPerFile = []; + // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a + // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use + // resolves. Only parsing is treated as a per-file, recoverable diagnostic. + $contents = []; foreach ($sources->filepaths as $filepath) { - // Read OUTSIDE the try so an I/O failure surfaces as itself, not a mislabeled - // "parse error" — only parsing is treated as a per-file, recoverable diagnostic. - $content = $this->fileReader->read($filepath); + $contents[$filepath] = $this->fileReader->read($filepath); + } + $globalAliases = $this->collectGlobalAliases($contents); + $astPerFile = []; + foreach ($contents as $filepath => $content) { try { - $astPerFile[$filepath] = $this->sourceParser->parse($content); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -577,14 +582,48 @@ public function check(FilepathArray $sources): DiagnosticCollector */ private function parseAll(FilepathArray $sources): array { - $astPerFile = []; + $contents = []; foreach ($sources->filepaths as $filepath) { - $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + $contents[$filepath] = $this->fileReader->read($filepath); + } + $globalAliases = $this->collectGlobalAliases($contents); + + $astPerFile = []; + foreach ($contents as $filepath => $content) { + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); } return $astPerFile; } + /** + * Merge every source's file-local type-alias table into one whole-program table, so an alias + * declared in one file can be used in another. A file whose own aliases are malformed (same-file + * duplicate / collision / unsupported body) raises here and is skipped — the same error + * re-surfaces (and, in check mode, is collected) when that file is parsed for real. + * + * @param array $contents filepath => source + * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> + */ + private function collectGlobalAliases(array $contents): array + { + $global = []; + foreach ($contents as $content) { + try { + foreach ($this->sourceParser->aliasTableOf($content) as $fqn => $entry) { + $global[$fqn] = $entry; + } + } catch (RuntimeException) { + // Any parse-time rejection — skip this file's aliases; the same error re-surfaces (and + // is collected in check mode) when the file is parsed for real. Both a nikic syntax + // error (PhpParser\Error) and an xphp scanner/alias error (XphpParseException) extend + // RuntimeException, so this catches every parse-time failure. + } + } + + return $global; + } + private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 66900aa3..c0414666 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -144,11 +144,39 @@ public function __construct(private readonly Parser $parser) } /** + * @param array, body:list}>|null $externalAliases + * a whole-program alias table (from {@see aliasTableOf} across every source) used for + * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). * @return list */ - public function parse(string $source): array + public function parse(string $source, ?array $externalAliases = null): array { - return $this->parseWithMap($source)[0]; + return $this->parseWithMap($source, $externalAliases)[0]; + } + + /** + * The file-local type-alias table for a single source — its `type` / `use type` declarations + * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across + * every source into a whole-program table so an alias declared in one file is usable in another. + * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via + * the main parse; the caller catches and skips a file that raises one here. + * + * @return array, body:list}> + */ + public function aliasTableOf(string $source): array + { + [, , , $cleaned, $byteOffsetMap, , $aliasMarkers] = $this->scanAndStrip($source); + // @infection-ignore-all ReturnRemoval -- optimization: an alias-free file (the common case) + // skips the re-parse; without it buildAliasTable([]) returns [] anyway. + if ($aliasMarkers === []) { + return []; + } + $ast = $this->parser->parse($cleaned); + if ($ast === null) { + return []; + } + /** @var list $ast — nikic's parse() returns array; keys are always 0..N-1. */ + return self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); } /** @@ -160,9 +188,10 @@ public function parse(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * + * @param array, body:list}>|null $externalAliases * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source): array + public function parseWithMap(string $source, ?array $externalAliases = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -182,7 +211,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3011,10 +3040,15 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $methodMarkers * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param array, body:list}>|null $externalAliases */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null): ?string { - $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / + // unsupported body) regardless; a whole-program table, when injected, is what expansion + // actually looks aliases up in so a use can reach an alias declared in another file. + $fileTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + $aliasTable = $externalAliases ?? $fileTable; $traverser = new NodeTraverser(); $visitor = new /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 4106e903..ab4f3ed0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -205,6 +205,42 @@ public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void $this->assertCompileThrows($files, 'unsupported body'); } + public function testAliasesAreVisibleAcrossFilesInTheSameBuild(): void + { + // Whole-program alias table: an alias declared in one file is usable in another (union and + // plain-class bodies both). + $dist = $this->compile([ + 'Types.xphp' => " " " "check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + } + + public function testAliasFileWithASyntaxErrorIsCollectedNotCrashed(): void + { + // The pre-pass re-parses an alias-bearing file to collect its aliases; a nikic SYNTAX error + // there (a PhpParserError, not an xphp RuntimeException) must be caught/skipped too, so check + // collects it for real rather than crashing the whole-program alias collection. + $files = [ + 'Broken.xphp' => "check($files)->hasErrors(), 'a syntax error in an alias file is collected, not crashed'); + } + public function testCyclicAliasIsRejectedInBothModes(): void { // A directly-or-transitively self-referential alias would expand without bound; it is diff --git a/test/fixture/compile/type_aliases/source/Consumer.xphp b/test/fixture/compile/type_aliases/source/Consumer.xphp new file mode 100644 index 00000000..7c6e75b4 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Consumer.xphp @@ -0,0 +1,16 @@ +widen('cross'); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index 85d16007..694918b0 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -18,6 +18,7 @@ return function (CompiledFixture $fixture): void { require $fixture->targetDir . '/Types.php'; + require $fixture->targetDir . '/Consumer.php'; // Pair === Dict>: the value is a Bag specialization holding a User. Assert::assertInstanceOf('App\\Aliases\\User', $pair->value()->get(), 'Pair expanded to Dict>'); @@ -39,4 +40,7 @@ // Union / nullable slots executed: `num('hi')` typed `int|string`, `maybe()` typed `?User`. Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); + + // Cross-file: Consumer.xphp used `Num` declared in Types.xphp. + Assert::assertSame('cross', $crossValue, 'an alias declared in Types.xphp was usable in Consumer.xphp'); }; From b5df8cc41f6d463909f959dfcf10effbae957015 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 20:24:56 +0000 Subject: [PATCH 07/17] docs(type-aliases): document union/nullable bodies and cross-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the type-alias docs to the delivered feature: union and nullable bodies and cross-file (whole-program) use. Rewrite the caveat (renamed to body/position limits — file-local and single-head no longer apply) and repoint the syntax/roadmap/ADR anchors; add the `xphp.alias_compound_in_non_slot` code; refresh the roadmap Shipped entry, the ADR consequences, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 ++++---- .../adr/0023-type-alias-declaration-syntax.md | 9 +-- docs/caveats.md | 61 +++++++++---------- docs/roadmap.md | 18 +++--- docs/syntax/type-aliases.md | 44 +++++++------ .../Monomorphize/XphpSourceParser.php | 2 +- 6 files changed, 90 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8546cb54..56b65fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Type aliases.** `type Name = Body;` (generic) and `type Name = Body;` - (non-generic) give a type a reusable name. An alias is a compile-time - substitution — expanded into its body before specialization, with no runtime - existence, so the emitted PHP never mentions the alias. It expands in every type - position, including as a generic argument (`Bag`), and composes with - nested and concrete-instantiation aliases (`type UserMap = Pair`). - v1 is file-local with single-head bodies; a cyclic (`xphp.alias_cycle`), - arity-mismatched (`xphp.alias_arity`), class-colliding - (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), or - unsupported-body (`xphp.alias_unsupported_body`, e.g. a union/nullable/closure - body) alias is a loud error in both `xphp compile` and `xphp check`. See +- **Type aliases.** Give a type a reusable name, in two forms: + `type Name = Body;` (generic) and `type Name = Body;` (non-generic). An + 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`): + 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`), and an alias declared + in one file is usable in another (**cross-file**, whole-program). 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), + or compound-in-non-slot (`xphp.alias_compound_in_non_slot`) 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 type parameters are determined by the argument values no longer needs the `::<>` diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md index 83879fb7..dc146c38 100644 --- a/docs/adr/0023-type-alias-declaration-syntax.md +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -61,9 +61,10 @@ 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: v1 is scoped to file-local, single-head bodies (see the - [caveat](../caveats.md#type-aliases-are-file-local-and-single-head)) — a safe subset, - with cross-file and richer bodies as later work. +- Trade-off: the delivered scope is single-head / union / nullable bodies, cross-file; + 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. ### Confirmation @@ -103,7 +104,7 @@ verified in both `compile` and `check`. ## More Information - [Type aliases](../syntax/type-aliases.md) and the - [file-local / single-head caveat](../caveats.md#type-aliases-are-file-local-and-single-head). + [file-local / single-head caveat](../caveats.md#type-alias-body-and-position-limits). - [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization; [ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias symbol is unnecessary). diff --git a/docs/caveats.md b/docs/caveats.md index 560b707b..5c8731e2 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -89,51 +89,50 @@ wherever inference can't see the type. It's always accepted, and an inferred call is identical to the turbofished one — so adding a turbofish never changes behavior, only makes the type explicit. -## Type aliases are file-local and single-head +## Type-alias body and position limits -[Type aliases](syntax/type-aliases.md) (`type Name<…> = Body;`) are a compile-time -substitution — a deliberately small first step, with three boundaries. +[Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single +head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body +are all supported, and an alias declared in one file is usable in another. Three +limits remain. ### ❌ What doesn't work ```php -// File Types.xphp -type UserId = Ident; +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 -// File Other.xphp — a DIFFERENT file -function f(): UserId { /* ... */ } // ✗ UserId is not visible here (file-local) - -type Num = int|string; // ✗ xphp.alias_unsupported_body — union body -type Maybe = ?Box; // ✗ xphp.alias_unsupported_body — nullable body -type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature +// A union / nullable alias is only usable as the WHOLE type of a slot: +type 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 ``` -An alias colliding with a class in **another** file is also not detected (a -same-file collision is — `xphp.alias_class_collision`). +Cross-file, an alias colliding with a **class in another file**, or the same alias +declared in **two files**, is not detected (both are within one file — +`xphp.alias_class_collision` / `xphp.alias_duplicate`). ### Why -An alias is expanded before specialization, during the per-file parse: it has no -runtime existence, and the parse has no cross-file symbol table, so an alias is -scoped to the file (and namespace) that declares it. The body is restricted to a -single class or generic *head* because that is the shape the monomorphizer can -substitute directly into a type position; a union / intersection / nullable / -closure body has no single identity to carry through specialization, so it is -rejected loudly rather than mis-compiled. Both boundaries are the same "make the -safe subset solid first" trade the rest of xphp makes — they are candidates to -lift later, not permanent design limits. +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. Cross-file expansion is a whole-program pre-pass that merges each +file's alias table; global duplicate/collision checking across that merge is a +later refinement. These are "make the safe subset solid first" trades, not +permanent design limits. ### ✅ Workaround -- Keep an alias and its uses in the **same file**. For a shared vocabulary, - declare the alias in each file that needs it (it's a zero-cost substitution). -- For a non-single-head type, write the type directly, or wrap it in a named - class or interface and alias *that*: - -```php -type UserId = int|string; // ✗ rejected -interface UserId { /* marker */ } // ✓ a named type you can alias/reference -``` +- For an intersection / DNF / closure body, write the type directly, or wrap it in + a named class or interface and alias *that*. +- 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. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fcd6cbd..073cc5ff 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 bodies + : single-head union and nullable bodies + : whole-program cross-file use Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -226,14 +227,17 @@ 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). -- Expands in every type position, including as a generic argument - (`Bag`); composes with nested and concrete-instantiation - aliases (`type UserMap = Pair`). -- File-local, single-head bodies (v1). Cyclic, arity-mismatched, - class-colliding, duplicate, and unsupported-body aliases are loud +- 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. +- **Cross-file**: an alias declared in one file is usable in another + (whole-program alias table). +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body + (intersection / DNF / closure), and compound-in-non-slot 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 - [file-local / single-head caveat](caveats.md#type-aliases-are-file-local-and-single-head). + [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). ### Naming and collisions diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index 0214ac11..dacb3626 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -9,6 +9,8 @@ existence, so the emitted PHP never mentions the alias name. type Pair = Dict>; // generic alias type UserId = Ident; // non-generic alias (a plain class) type UserMap = Pair; // a concrete instantiation of another alias +type Num = int|string; // union body +type MaybeUser = ?User; // nullable body ``` ## Example @@ -59,12 +61,17 @@ no separate code path and no runtime cost. ## Rules -- **Two forms**: `type Name = Body;` (generic) and +- **Declaration forms**: `type Name = Body;` (generic) and `type Name = Body;` (non-generic). The parameter list is optional; the separator is `=`. -- The alias expands in **every type position** — parameter, return, - property, `new`, turbofish argument, `extends`/`implements`, and as a - **generic argument** of another type (`Bag`). +- **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). +- **Cross-file**: an alias declared in one file is usable in another file + of the same build (the whole program shares one alias table). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -79,24 +86,27 @@ 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` — see caveats below. + - `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). ## Caveats -Aliases are intentionally a small, safe first step. See -[caveats → type aliases](../caveats.md#type-aliases-are-file-local-and-single-head) +Union and nullable bodies and cross-file use all work; the remaining +limits are the body shape and the positions a compound alias can take. See +[caveats → type-alias body and position limits](../caveats.md#type-alias-body-and-position-limits) for the details and the reasons: -- **File-local.** An alias is usable only within the file that declares - it (and only within its declaring namespace). Cross-file / importable - aliases are not supported yet. -- **Single-head bodies.** The body must be a single class or generic type - (`Dict`, `Ident`, `Bag`). A union, intersection, nullable, or - closure-signature body (`int|string`, `?Box`, `Closure(int): int`) is - rejected with `xphp.alias_unsupported_body` — use a bare type or a named - class. -- **Same-file collision detection.** An alias colliding with a class - declared in *another* file is not detected. +- **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`. +- **Cross-file collision / duplicate not detected.** An alias colliding + with a class, or the same alias declared, in a *different* file is not + flagged (both are within one file). ## See also diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index c0414666..21afd094 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -155,7 +155,7 @@ public function parse(string $source, ?array $externalAliases = null): array } /** - * The file-local type-alias table for a single source — its `type` / `use type` declarations + * The file-local type-alias table for a single source — its `type` declarations * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across * every source into a whole-program table so an alias declared in one file is usable in another. * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via From 0dbd66360240debe057125ccff1151a2be74278d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:03:11 +0000 Subject: [PATCH 08/17] feat(monomorphize): apply defaults for generic type-alias parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias's own parameters may declare defaults (`type P = Dict;`), which `parseTypeParamList` already parses but expansion discarded — using fewer args than params was a flat `xphp.alias_arity`. Retain the full per-param entries in the alias marker/table, resolve each default against the alias's params (so `B = A` and chained `C = B` fill from earlier arguments), and pad missing trailing arguments at expansion. The valid arity is now `required <= given <= total`; the message keeps the exact `expects N` form with no defaults and uses `between R and N` only when defaults make the count a range. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 4 +- .../Monomorphize/XphpSourceParser.php | 139 ++++++++++++++---- .../Monomorphize/TypeAliasIntegrationTest.php | 66 +++++++++ 3 files changed, 178 insertions(+), 31 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 466d5f0f..a9c4dbe2 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -31,6 +31,8 @@ * 4. Emit rewritten user code — rewrite each original source AST (strip generic class defs, * rewrite generic Name references), pretty-print, and write to the target directory. * 5. Persist registry — write .xphp-cache/registry.json. + * + * @phpstan-import-type BoundDict from XphpSourceParser */ final readonly class Compiler { @@ -603,7 +605,7 @@ private function parseAll(FilepathArray $sources): array * re-surfaces (and, in check mode, is collected) when that file is parsed for real. * * @param array $contents filepath => source - * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> + * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> */ private function collectGlobalAliases(array $contents): array { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 21afd094..43470d62 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -144,7 +144,7 @@ public function __construct(private readonly Parser $parser) } /** - * @param array, body:list}>|null $externalAliases + * @param array, body:list}>|null $externalAliases * a whole-program alias table (from {@see aliasTableOf} across every source) used for * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). * @return list @@ -161,7 +161,7 @@ public function parse(string $source, ?array $externalAliases = null): array * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via * the main parse; the caller catches and skips a file that raises one here. * - * @return array, body:list}> + * @return array, body:list}> */ public function aliasTableOf(string $source): array { @@ -188,7 +188,7 @@ public function aliasTableOf(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * - * @param array, body:list}>|null $externalAliases + * @param array, body:list}>|null $externalAliases * @return array{0: list, 1: ByteOffsetMap} */ public function parseWithMap(string $source, ?array $externalAliases = null): array @@ -349,7 +349,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:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -363,7 +363,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2469,7 +2469,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, paramNames:list, body:?list, bytePosition:int, line:int}, 1: int}|null + * @return array{0: array{name:string, params:list, body:?list, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2497,9 +2497,10 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? } // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on - // a class header) so recognition never throws; whether an alias param may carry a default or - // variance marker is a semantic question for the expansion step, not for scan-time stripping. - $paramNames = []; + // a class header) so recognition never throws. The full per-param entries — carrying each + // param's optional bound and default — are retained (not just the names): expansion applies + // the defaults (fewer args than params) and enforces the bounds. + $params = []; $afterName = self::skipWs($tokens, $nameIdx + 1); $afterNameTok = $tokens[$afterName] ?? null; if ($afterNameTok === null) { @@ -2510,8 +2511,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($parsed === null) { return null; } - [$paramEntries, $paramsEndIdx] = $parsed; - $paramNames = array_map(static fn (array $entry): string => $entry['name'], $paramEntries); + [$params, $paramsEndIdx] = $parsed; // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. @@ -2543,7 +2543,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return [ [ 'name' => $nameTok->text, - 'paramNames' => $paramNames, + 'params' => $params, 'body' => $body, 'bytePosition' => $tokens[$typeIdx]->pos, 'line' => $tokens[$typeIdx]->line, @@ -2921,8 +2921,8 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @return array, body:list}> + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:list}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -2983,7 +2983,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff self::CODE_ALIAS_CLASS_COLLISION, ); } - $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; + $table[$fqn] = ['params' => $marker['params'], 'body' => $marker['body']]; } return $table; } @@ -3039,8 +3039,8 @@ 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 array, body:list}>|null $externalAliases + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param array, body:list}>|null $externalAliases */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null): ?string { @@ -3069,7 +3069,7 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:list}> $aliasTable file-local + * @param array, body:list}> $aliasTable file-local * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( @@ -3091,6 +3091,14 @@ public function __construct( */ private array $aliasBodyCache = []; + /** + * Cache of resolved alias parameter defaults keyed by alias FQN — aligned by position to + * the alias's params, null where a param has no default. Resolved once, like the body. + * + * @var array> + */ + private array $aliasDefaultsCache = []; + // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. public function enterNode(Node $node): ?Node @@ -4173,17 +4181,10 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar XphpSourceParser::CODE_ALIAS_CYCLE, ); } - if (count($expandedArgs) !== count($entry['paramNames'])) { - throw new XphpParseException( - "Type alias `{$ref->name}` expects " . count($entry['paramNames']) - . ' type argument(s), ' . count($expandedArgs) . ' given.', - $line, - XphpSourceParser::CODE_ALIAS_ARITY, - ); - } + $paddedArgs = $this->padAliasArgs($ref->name, $entry, $expandedArgs, $line); $subst = []; - foreach ($entry['paramNames'] as $k => $paramName) { - $subst[$paramName] = $expandedArgs[$k]; + foreach (array_column($entry['params'], 'name') as $k => $paramName) { + $subst[$paramName] = $paddedArgs[$k]; } $members = []; foreach ($this->resolveAliasBody($ref->name, $entry) as $bodyMember) { @@ -4195,12 +4196,62 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar return $members; } + /** + * Reconcile the supplied type arguments against an alias's parameters, filling missing + * trailing arguments from the parameters' defaults. A default may reference an earlier + * parameter (`B = A`), so each is substituted with the arguments already positioned. The + * 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 list $expandedArgs + * @return list + */ + private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, int $line): array + { + $total = count($entry['params']); + $required = 0; + foreach ($entry['params'] as $param) { + if ($param['default'] === null) { + $required++; + } + } + $given = count($expandedArgs); + if ($given < $required || $given > $total) { + // @infection-ignore-all CastString -- $total is interpolated into the message + // either way; the cast only keeps both ternary branches typed `string`. + $expected = $required === $total + ? (string) $total + : "between {$required} and {$total}"; + throw new XphpParseException( + "Type alias `{$fqn}` expects {$expected} type argument(s), {$given} given.", + $line, + XphpSourceParser::CODE_ALIAS_ARITY, + ); + } + $defaults = $this->resolveAliasDefaults($fqn, $entry); + $paramNames = array_column($entry['params'], 'name'); + $padded = $expandedArgs; + for ($i = $given; $i < $total; $i++) { + $subst = []; + foreach ($padded as $k => $arg) { + $subst[$paramNames[$k]] = $arg; + } + // @infection-ignore-all CoalesceRemoval -- indices [$given,$total) are exactly the + // trailing params, every one of which has a default (required params form a prefix), + // so $defaults[$i] is never null here; the coalesce is a defensive floor. + $default = $defaults[$i] ?? throw new \LogicException('padded slot without a default'); + $padded[] = self::substituteTypeRef($default, $subst); + } + return $padded; + } + /** * Resolve an alias's raw body against the current namespace context, with the alias's own * type parameters pushed so `A` / `B` become type-param references rather than qualified * class names. Cached per alias FQN. * - * @param array{paramNames:list, body:list} $entry + * @param array{params:list, body:list} $entry * @return list */ private function resolveAliasBody(string $fqn, array $entry): array @@ -4214,12 +4265,40 @@ private function resolveAliasBody(string $fqn, array $entry): array // 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[] = $entry['paramNames']; + $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } + /** + * Resolve an alias's raw parameter DEFAULTS against the current namespace context, with the + * alias's own parameters in scope so a default that references an earlier param (`B = A`) + * resolves to a type-param leaf. Returns one entry per parameter, aligned by position: + * the resolved default TypeRef, or null where the parameter has no default. Cached per FQN. + * + * @param array{params:list, body:list} $entry + * @return list + */ + private function resolveAliasDefaults(string $fqn, array $entry): array + { + // @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->aliasDefaultsCache[$fqn])) { + return $this->aliasDefaultsCache[$fqn]; + } + $saved = $this->typeParamStack; + $this->typeParamStack[] = array_column($entry['params'], 'name'); + $resolved = array_map( + fn (array $param): ?TypeRef => $param['default'] === null + ? null + : $this->resolveTypeRef($param['default']), + $entry['params'], + ); + $this->typeParamStack = $saved; + return $this->aliasDefaultsCache[$fqn] = $resolved; + } + /** * Replace type-parameter leaves in a resolved TypeRef tree using a name → concrete map. * diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index ab4f3ed0..18d370e0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -261,6 +261,57 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); } + public function testAliasParameterDefaultReferencingAnEarlierParameterIsFilled(): void + { + // `type P` used as `P` fills the omitted B with A (= int), so it specializes to + // the SAME Dict as the explicit `P`, and NOT the same as `P`. The + // specialization hash is non-deterministic, but equality between two emitted FQNs is exact. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict;\nclass C {\n public function omitted(): P { return new Dict::(1, 2); }\n public function explicitSame(): P { return new Dict::(1, 2); }\n public function explicitDiff(): P { return new Dict::(1, 'x'); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitSame'), self::specFqn($use, 'omitted'), 'P fills B = A = int, matching P'); + self::assertNotSame(self::specFqn($use, 'explicitDiff'), self::specFqn($use, 'omitted'), 'P is not P'); + } + + public function testAliasParameterConcreteDefaultIsFilled(): void + { + // A concrete (non-param-referencing) default: `type Q` used as `Q` fills B + // with string, matching the explicit `Q` and differing from `Q`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict;\nclass C {\n public function omitted(): Q { return new Dict::(1, 'x'); }\n public function explicitSame(): Q { return new Dict::(1, 'x'); }\n public function explicitDiff(): Q { return new Dict::(1, 2); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitSame'), self::specFqn($use, 'omitted'), 'Q fills B = string, matching Q'); + self::assertNotSame(self::specFqn($use, 'explicitDiff'), self::specFqn($use, 'omitted'), 'Q is not Q'); + } + + public function testAliasDefaultChainFillsTransitively(): void + { + // A default may reference an earlier param that is itself defaulted: `P` used + // as `P` fills B = A = int, then C = B = int — the same specialization as `P`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { public function __construct(public X \$x, public Y \$y, public Z \$z) {} }\ntype P = Trip;\nclass C {\n public function omitted(): P { return new Trip::(1, 2, 3); }\n public function explicitFull(): P { return new Trip::(1, 2, 3); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitFull'), self::specFqn($use, 'omitted'), 'P fills B = A = int then C = B = int'); + } + + public function testAliasArityRangeMessageAppearsOnlyWithDefaults(): void + { + // With a default present the valid arity is a RANGE (required..total); too many args reports + // the "between R and N" form. (A no-default alias keeps the exact "expects N" form — pinned by + // testAliasArityMismatchIsRejectedInBothModes.) + $files = [ + 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_ARITY, 'expects between 1 and 2 type argument(s), 3 given'); + $this->assertCompileThrows($files, 'expects between 1 and 2 type argument(s), 3 given'); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear @@ -403,6 +454,21 @@ private static function read(string $dir, string $file): string return is_file($path) ? (file_get_contents($path) ?: '') : ''; } + /** + * The emitted specialization FQN (`\XPHP\Generated\…`) a given method returns. The hash is + * non-deterministic, so callers compare two of these for equality rather than asserting a literal. + */ + private static function specFqn(string $emitted, string $method): string + { + self::assertSame( + 1, + preg_match('/function ' . preg_quote($method, '/') . '\(\): (\\\\XPHP\\\\Generated\\\\[^\s{]+)/', $emitted, $m), + "method {$method}() specialization not found in emitted source", + ); + + return $m[1]; + } + private static function rrmdir(string $dir): void { if (!is_dir($dir)) { From d97d702f5be586bc80ddcf56da284ca2e39f31bb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:28:59 +0000 Subject: [PATCH 09/17] feat(monomorphize): enforce generic type-alias parameter bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias's parameters may declare bounds (`type B = Bag;`), previously parsed and dropped. Enforce them: because alias expansion runs per file before the whole-program hierarchy exists, each used bounded alias records an AliasBoundObligation (its resolved parameters + concrete padded arguments + use-site location), collected across files and verified once the hierarchy is built by AliasBoundValidator via a new Registry::checkAliasBounds — the same check a class instantiation runs, so a violation surfaces as an identical xphp.bound_violation (thrown in compile, collected in check). An argument whose top level is a type parameter (`B` inside `class C`) is skipped (absent from the hierarchy, it would be spuriously rejected); a concrete head over a type-param inner (`Coll`) is checked, since bounds erase generic arguments. Only file-local generic aliases reach enforcement — a cross-file generic-alias use is a separate unsupported case that hard-errors as an undefined template — so a captured bound always resolves in the context it was declared, with no cross-file misresolution. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/AliasBoundObligation.php | 30 +++++ .../AliasBoundObligationCollector.php | 34 +++++ .../Monomorphize/AliasBoundValidator.php | 33 +++++ src/Transpiler/Monomorphize/Compiler.php | 16 ++- src/Transpiler/Monomorphize/Registry.php | 28 +++++ .../Monomorphize/XphpSourceParser.php | 116 ++++++++++++----- .../Monomorphize/TypeAliasIntegrationTest.php | 117 ++++++++++++++++++ 7 files changed, 341 insertions(+), 33 deletions(-) create mode 100644 src/Transpiler/Monomorphize/AliasBoundObligation.php create mode 100644 src/Transpiler/Monomorphize/AliasBoundObligationCollector.php create mode 100644 src/Transpiler/Monomorphize/AliasBoundValidator.php diff --git a/src/Transpiler/Monomorphize/AliasBoundObligation.php b/src/Transpiler/Monomorphize/AliasBoundObligation.php new file mode 100644 index 00000000..ec5d91ee --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligation.php @@ -0,0 +1,30 @@ + $typeParams the alias's resolved parameters (name + bound), in order + * @param list $args the concrete, padded type arguments supplied at the use site + */ + public function __construct( + public array $typeParams, + public array $args, + public string $label, + public SourceLocation $location, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php new file mode 100644 index 00000000..6b98262c --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -0,0 +1,34 @@ + */ + private array $obligations = []; + + /** + * @param list $typeParams + * @param list $args + */ + public function add(array $typeParams, array $args, string $label, SourceLocation $location): void + { + $this->obligations[] = new AliasBoundObligation($typeParams, $args, $label, $location); + } + + /** @return list */ + public function all(): array + { + return $this->obligations; + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundValidator.php b/src/Transpiler/Monomorphize/AliasBoundValidator.php new file mode 100644 index 00000000..584f6711 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundValidator.php @@ -0,0 +1,33 @@ +all() as $obligation) { + Registry::checkAliasBounds( + $obligation->typeParams, + $obligation->args, + $hierarchy, + $obligation->label, + $diagnostics, + $obligation->location, + ); + } + } +} diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index a9c4dbe2..b68e9ad1 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -69,7 +69,8 @@ public function compile( // Phase 0: parse every source up front. The TypeHierarchy (used to validate generic // bounds at recordInstantiation time) needs to see every class/interface/trait // declaration *before* any instantiation is recorded, so parsing has to finish first. - $astPerFile = $this->parseAll($sources); + $aliasBoundObligations = new AliasBoundObligationCollector(); + $astPerFile = $this->parseAll($sources, $aliasBoundObligations); $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy); @@ -126,6 +127,9 @@ public function compile( // reaches emission as broken PHP. $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds now that the hierarchy exists (the obligations were + // captured during parse, before it did) — compile mode has no collector, so a violation throws. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator // couldn't catch (e.g. `class P { f(): Container }` where @@ -457,6 +461,7 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); + $aliasBoundObligations = new AliasBoundObligationCollector(); // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use // resolves. Only parsing is treated as a per-file, recoverable diagnostic. @@ -468,7 +473,7 @@ public function check(FilepathArray $sources): DiagnosticCollector $astPerFile = []; foreach ($contents as $filepath => $content) { try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $aliasBoundObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -528,6 +533,9 @@ public function check(FilepathArray $sources): DiagnosticCollector $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds (obligations captured during parse) now the hierarchy + // exists; check mode collects each violation as an xphp.bound_violation and continues. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy, $diagnostics); $registry->validateInnerVariance(); // Closure-signature conformance at the statically-visible literal site // (a `Closure(...)` return handing back a closure literal). In @@ -582,7 +590,7 @@ public function check(FilepathArray $sources): DiagnosticCollector * * @return array> */ - private function parseAll(FilepathArray $sources): array + private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { $contents = []; foreach ($sources->filepaths as $filepath) { @@ -592,7 +600,7 @@ private function parseAll(FilepathArray $sources): array $astPerFile = []; foreach ($contents as $filepath => $content) { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $obligations); } return $astPerFile; diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6e55b18b..67e710fc 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -748,6 +748,34 @@ private static function varianceEdgeUnprovableMessage( ); } + /** + * Bound-check a used type alias's parameters against its concrete arguments — the same check + * {@see validateBounds} runs for a class instantiation, grounding any sibling-referencing bound + * (``) against the supplied args first. Exposed statically so the post-hierarchy + * {@see AliasBoundValidator} pass reports through the identical `checkBounds` seam (a violation + * surfaces as the same `xphp.bound_violation`). + * + * @param list $typeParams + * @param list $args + */ + public static function checkAliasBounds( + array $typeParams, + array $args, + TypeHierarchy $hierarchy, + string $label, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, + ): void { + self::checkBounds( + self::groundSiblingBounds($typeParams, $args), + $args, + $hierarchy, + $label, + $diagnostics, + $callSite, + ); + } + /** * Reusable bound check for any (typeParams, concreteArgs) pair against a hierarchy. * diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 43470d62..ac0d0cfd 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -15,6 +15,7 @@ use PhpParser\Parser; use PhpToken; use RuntimeException; +use XPHP\Diagnostics\SourceLocation; /** * Parses .xphp source text into an AST with generic metadata, supporting @@ -147,11 +148,15 @@ public function __construct(private readonly Parser $parser) * @param array, body:list}>|null $externalAliases * a whole-program alias table (from {@see aliasTableOf} across every source) used for * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). + * @param ?string $filepath the source file, threaded only so a captured alias-bound obligation + * can carry an accurate SourceLocation; null on the standalone parse path. + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations, + * verified after the hierarchy is built; null (inert) on the standalone parse path. * @return list */ - public function parse(string $source, ?array $externalAliases = null): array + public function parse(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - return $this->parseWithMap($source, $externalAliases)[0]; + return $this->parseWithMap($source, $externalAliases, $filepath, $obligations)[0]; } /** @@ -189,9 +194,10 @@ public function aliasTableOf(string $source): array * (the common case for files without `T[]` array-suffix sugar). * * @param array, body:list}>|null $externalAliases + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source, ?array $externalAliases = null): array + public function parseWithMap(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -211,7 +217,7 @@ public function parseWithMap(string $source, ?array $externalAliases = null): ar } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases, $filepath, $obligations); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3041,8 +3047,9 @@ private static function collectClassLikeFqns(array $ast): array * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers * @param array, body:list}>|null $externalAliases + * @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, ?array $externalAliases = null): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string { // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / // unsupported body) regardless; a whole-program table, when injected, is what expansion @@ -3054,7 +3061,7 @@ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMa /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable, $filepath, $obligations) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -3069,8 +3076,10 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:list}> $aliasTable file-local - * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. + * @param array, body:list}> $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 */ public function __construct( private array $classMarkers, @@ -3079,6 +3088,8 @@ public function __construct( private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, private array $aliasTable, + private ?string $filepath, + private ?AliasBoundObligationCollector $obligations, ) { $this->ctx = new NamespaceContext(); } @@ -3092,12 +3103,13 @@ public function __construct( private array $aliasBodyCache = []; /** - * Cache of resolved alias parameter defaults keyed by alias FQN — aligned by position to - * the alias's params, null where a param has no default. Resolved once, like the body. + * Cache of resolved alias parameters keyed by alias FQN — each raw param entry resolved + * (against the use-site context, with the alias's params in scope) to a TypeParam carrying + * its bound and default. Feeds both default-padding and bound enforcement. Resolved once. * - * @var array> + * @var array> */ - private array $aliasDefaultsCache = []; + private array $aliasParamsCache = []; // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. @@ -4182,6 +4194,7 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar ); } $paddedArgs = $this->padAliasArgs($ref->name, $entry, $expandedArgs, $line); + $this->captureAliasBoundObligation($ref->name, $entry, $paddedArgs, $line); $subst = []; foreach (array_column($entry['params'], 'name') as $k => $paramName) { $subst[$paramName] = $paddedArgs[$k]; @@ -4196,6 +4209,47 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar return $members; } + /** + * Record a deferred bound-check for a used alias whose parameters declare bounds, to be + * verified once the whole-program hierarchy exists ({@see AliasBoundValidator}). Captured + * only when a collector is threaded in (the compile/check path — inert for standalone / LSP + * parse) and every supplied argument is top-level ground: a bare type-param argument + * (`B` inside `class C`) is absent from the hierarchy and would be spuriously + * rejected, so it is skipped, whereas a concrete head over a type-param inner (`Box`) IS + * captured (bounds erase generic arguments). + * + * Only a generic alias has parameters, hence bounds; and a generic alias only expands where + * it is FILE-LOCAL (a cross-file generic-alias use is a separate unsupported case that + * hard-errors as an undefined template, never reaching here). So a captured bound always + * 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 list $paddedArgs + */ + private function captureAliasBoundObligation(string $fqn, array $entry, array $paddedArgs, int $line): void + { + if ($this->obligations === null) { + return; + } + // A top-level type-param argument (`B` in `class C`) is absent from the hierarchy + // and would be spuriously rejected; skip the whole obligation. A concrete head over a + // type-param inner (`Coll`) is kept — bounds erase generic arguments. (An alias with + // no bounds is captured harmlessly: checkBounds is a no-op for a param without a bound, + // so gating on "has a bound" would be an unobservable optimization.) + foreach ($paddedArgs as $arg) { + if ($arg->isTypeParam) { + return; + } + } + $this->obligations->add( + $this->resolveAliasParams($fqn, $entry), + $paddedArgs, + "type alias `{$fqn}`", + new SourceLocation($this->filepath ?? '', $line), + ); + } + /** * Reconcile the supplied type arguments against an alias's parameters, filling missing * trailing arguments from the parameters' defaults. A default may reference an earlier @@ -4229,7 +4283,7 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in XphpSourceParser::CODE_ALIAS_ARITY, ); } - $defaults = $this->resolveAliasDefaults($fqn, $entry); + $params = $this->resolveAliasParams($fqn, $entry); $paramNames = array_column($entry['params'], 'name'); $padded = $expandedArgs; for ($i = $given; $i < $total; $i++) { @@ -4239,8 +4293,8 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in } // @infection-ignore-all CoalesceRemoval -- indices [$given,$total) are exactly the // trailing params, every one of which has a default (required params form a prefix), - // so $defaults[$i] is never null here; the coalesce is a defensive floor. - $default = $defaults[$i] ?? throw new \LogicException('padded slot without a default'); + // so $params[$i]->default is never null here; the coalesce is a defensive floor. + $default = $params[$i]->default ?? throw new \LogicException('padded slot without a default'); $padded[] = self::substituteTypeRef($default, $subst); } return $padded; @@ -4272,31 +4326,35 @@ private function resolveAliasBody(string $fqn, array $entry): array } /** - * Resolve an alias's raw parameter DEFAULTS against the current namespace context, with the - * alias's own parameters in scope so a default that references an earlier param (`B = A`) - * resolves to a type-param leaf. Returns one entry per parameter, aligned by position: - * the resolved default TypeRef, or null where the parameter has no default. Cached per FQN. + * Resolve an alias's raw parameter entries against the current namespace context, with the + * alias's own parameters in scope so a bound / default that references a param (`T : A`, + * `B = A`) resolves to a type-param leaf. Returns one TypeParam per parameter, in order, + * carrying the resolved bound and default. Cached per FQN; feeds both default-padding + * (`padAliasArgs`) and bound enforcement (`captureAliasBoundObligation`). * * @param array{params:list, body:list} $entry - * @return list + * @return list */ - private function resolveAliasDefaults(string $fqn, array $entry): array + private function resolveAliasParams(string $fqn, array $entry): array { - // @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->aliasDefaultsCache[$fqn])) { - return $this->aliasDefaultsCache[$fqn]; + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolution is + // deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasParamsCache[$fqn])) { + return $this->aliasParamsCache[$fqn]; } $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map( - fn (array $param): ?TypeRef => $param['default'] === null - ? null - : $this->resolveTypeRef($param['default']), + fn (array $param): TypeParam => new TypeParam( + $param['name'], + $this->buildBoundExpr($param), + $this->buildDefault($param), + $param['variance'], + ), $entry['params'], ); $this->typeParamStack = $saved; - return $this->aliasDefaultsCache[$fqn] = $resolved; + return $this->aliasParamsCache[$fqn] = $resolved; } /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 18d370e0..617051f5 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -312,6 +312,106 @@ public function testAliasArityRangeMessageAppearsOnlyWithDefaults(): void $this->assertCompileThrows($files, 'expects between 1 and 2 type argument(s), 3 given'); } + public function testAliasParameterBoundViolationIsRejectedInBothModes(): void + { + // A ground argument that does not satisfy an alias parameter's bound is a loud error, routed + // through the SAME check a class instantiation uses — an identical `xphp.bound_violation` with + // the "type alias" label. `check` collects it; `compile` throws (a RuntimeException, not the + // parse exception, since the check runs post-hierarchy). + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B { return new Bag::(1); }\n", + ]; + $collector = $this->check($files); + self::assertRejected($collector, Registry::CODE_BOUND_VIOLATION, 'Generic bound violated while instantiating type alias `App\\B`'); + // The diagnostic points at the USE-site file — a captured obligation carries a real location. + $violations = array_values(array_filter($collector->all(), static fn ($d): bool => $d->code === Registry::CODE_BOUND_VIOLATION)); + self::assertStringEndsWith('C.xphp', $violations[0]->location?->file ?? ''); + $this->assertCompileThrowsRuntime($files, '"int" does not extend/implement "App\\Named"'); + } + + public function testAliasParameterBoundSatisfiedByAGroundArgumentCompiles(): void + { + // A ground argument that satisfies the bound compiles cleanly — no false positive. + $use = self::read($this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function f(): B { return new Bag::(new Widget()); } }\n", + ]), 'C.php'); + + self::assertStringContainsString('\\XPHP\\Generated\\App\\Bag\\', self::specFqn($use, 'f')); + } + + public function testAliasParameterBoundOnATopLevelTypeParameterArgumentIsSkipped(): void + { + // `B` inside `class G`: the argument's top level is a type parameter, absent from the + // hierarchy, so checking it would spuriously reject. It is skipped (the alias erases to + // `Bag`, whose own bounds — none here — still apply when G specializes). No false positive. + $dist = $this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass G { public function __construct(public X \$x) {} public function f(): B { return new Bag::(\$this->x); } }\nclass H { public function make(): G { return new G::(1); } }\n", + ]); + + self::assertStringContainsString('class', self::read($dist, 'C.php')); + } + + public function testAliasParameterBoundChecksAConcreteHeadOverATypeParameterInnerArgument(): void + { + // `B>` inside `class G`: the argument's TOP LEVEL is the concrete `Coll` (not a type + // parameter), so it is checked even though its inner arg is a type parameter — bounds erase + // generic arguments. `Coll` does not implement `Named`, so this is a violation, not a skip. + $files = [ + 'C.xphp' => " {}\nclass Bag { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass G { public function f(): B> { throw new \\Exception(); } }\nclass H { public function make(): G { return new G::(); } }\n", + ]; + self::assertRejected($this->check($files), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Named"'); + } + + public function testAliasParameterBoundWithAnUnknownGroundClassIsRejected(): void + { + // A ground class the hierarchy was not built from (e.g. a vendor class) is an UNKNOWN verdict, + // which — exactly as for class generics — is rejected (the compiler cannot prove the bound), so + // no knowably-unprovable specialization is emitted silently. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B<\\DateTime> { return new Bag::<\\DateTime>(new \\DateTime()); }\n", + ]; + self::assertRejected($this->check($files), Registry::CODE_BOUND_VIOLATION, 'is not in the source set the hierarchy was built from'); + } + + public function testAliasParameterSiblingReferencingBoundIsGroundedAndChecked(): void + { + // A bound referencing an earlier sibling parameter (``) is grounded against the + // supplied args before checking — `Pair` fails (int is not Named) while + // `Pair` passes (Widget implements Named). + $bad = [ + 'C.xphp' => " {}\ntype Pair = Two;\nfunction f(): Pair { throw new \\Exception(); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Named"'); + + $good = self::read($this->compile([ + 'C.xphp' => " {}\ntype Pair = Two;\nclass C { public function f(): Pair { throw new \\Exception(); } }\n", + ]), 'C.php'); + self::assertStringContainsString('\\XPHP\\Generated\\App\\Two\\', self::specFqn($good, 'f')); + } + + public function testAliasParameterBoundIsReportedPerGroundUseSite(): void + { + // Obligations are per use site (no FQN de-dup like the Registry): two ground violating uses of + // the same bounded alias yield two collected diagnostics in check mode. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B { return new Bag::(1); }\nfunction g(): B { return new Bag::('x'); }\n", + ]; + $violations = array_filter($this->check($files)->all(), static fn ($d): bool => $d->code === Registry::CODE_BOUND_VIOLATION); + self::assertCount(2, $violations); + } + + public function testABadDefaultOnAnUnusedAliasIsNotChecked(): void + { + // Obligations are captured only where an alias is USED; an alias declared with a default that + // would violate its own bound but never instantiated emits nothing (unlike a class template, + // which is checked at declaration). Documented divergence, not a bug: an unused alias is inert. + $dist = $this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function unrelated(): int { return 1; } }\n", + ]); + + self::assertStringContainsString('function unrelated(): int', self::read($dist, 'C.php')); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear @@ -389,6 +489,23 @@ private function assertCompileThrows(array $files, string $needle): void } } + /** + * Like {@see assertCompileThrows}, but for a violation raised AFTER parsing (an alias parameter + * bound, checked once the hierarchy exists) — which throws a plain RuntimeException, not the parse + * exception. + * + * @param array $files + */ + private function assertCompileThrowsRuntime(array $files, string $needle): void + { + try { + $this->compile($files); + self::fail('compile must reject the alias bound violation loudly'); + } catch (\RuntimeException $e) { + self::assertStringContainsString($needle, $e->getMessage()); + } + } + private const LIB = <<<'PHP' Date: Thu, 30 Jul 2026 23:30:47 +0000 Subject: [PATCH 10/17] docs(type-aliases): document parameter defaults and bounds Record the two new generic-alias parameter capabilities: defaults (`type P`, trailing arguments may be omitted) and enforced bounds (`type B`, a violating argument is xphp.bound_violation). Update the syntax tour's Rules, the roadmap Shipped entry, and the CHANGELOG, and note in the caveat that a generic alias is file-local (a non-generic alias is cross-file). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 ++++++++++------- docs/caveats.md | 20 +++++++++++++++----- docs/roadmap.md | 12 ++++++++---- docs/syntax/type-aliases.md | 19 +++++++++++++++---- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b65fd9..4157bb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`), and an alias declared - in one file is usable in another (**cross-file**, whole-program). A cyclic - (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding - (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), + 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), like a generic class. A non-generic alias declared in one file is + usable in another (**cross-file**, whole-program); a generic alias is file-local. + 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), - or compound-in-non-slot (`xphp.alias_compound_in_non_slot`) alias is a loud error - in both `xphp compile` and `xphp check`. See - [type aliases](docs/syntax/type-aliases.md). + compound-in-non-slot (`xphp.alias_compound_in_non_slot`), 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 type parameters are determined by the argument values no longer needs the `::<>` turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers diff --git a/docs/caveats.md b/docs/caveats.md index 5c8731e2..30594a6c 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -93,8 +93,8 @@ behavior, only makes the type explicit. [Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body -are all supported, and an alias declared in one file is usable in another. Three -limits remain. +are all supported; parameters may carry defaults and bounds; and a **non-generic** +alias declared in one file is usable in another. Three limits remain. ### ❌ What doesn't work @@ -109,6 +109,12 @@ 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 + +// A GENERIC alias (one with type parameters) is file-local: +// File Types.xphp +type Pair = Dict; +// File Other.xphp — a DIFFERENT file +function f(): Pair { /* … */ } // ✗ Pair is not visible here (generic alias is file-local) ``` Cross-file, an alias colliding with a **class in another file**, or the same alias @@ -123,9 +129,11 @@ lower cleanly into a PHP type node. An intersection or DNF pulls in *distributio 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. Cross-file expansion is a whole-program pre-pass that merges each -file's alias table; global duplicate/collision checking across that merge is a -later refinement. These are "make the safe subset solid first" trades, not -permanent design limits. +file's alias table; a *generic* alias use is expanded before that table is +consulted for arguments, so a generic alias resolves only within its own file +(and its declared parameter bounds are enforced there). Global duplicate/collision +checking across the merge is a later refinement. These are "make the safe subset +solid first" trades, not permanent design limits. ### ✅ Workaround @@ -133,6 +141,8 @@ permanent design limits. a named class or interface and alias *that*. - 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 a generic alias in each file that uses it (a zero-cost substitution), or + reference the underlying generic type directly across files. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 073cc5ff..85075c72 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -231,11 +231,15 @@ upcoming one. 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. -- **Cross-file**: an alias declared in one file is usable in another - (whole-program alias table). +- 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. +- **Cross-file**: a non-generic alias declared in one file is usable in + another (whole-program alias table); a generic alias is file-local. - Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body - (intersection / DNF / closure), and compound-in-non-slot uses are loud - compile errors in both `compile` and `check`, each with a stable code. + (intersection / DNF / closure), compound-in-non-slot, and + bound-violating uses are loud compile errors in both `compile` and + `check`, each with a stable code. - 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 dacb3626..c140a6c9 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -70,8 +70,16 @@ no separate code path and no runtime cost. 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). -- **Cross-file**: an alias declared in one file is usable in another file - of the same build (the whole program shares one alias table). +- **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 + `type B = Bag;` (a use whose argument does not satisfy the + bound is a compile error, the same `xphp.bound_violation` a class + instantiation raises). +- **Cross-file**: a **non-generic** alias declared in one file is usable in + another of the same build (the whole program shares one alias table). A + **generic** alias (one with type parameters) is **file-local** — use it + in the file that declares it (see caveats). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -81,8 +89,11 @@ no separate code path and no runtime cost. both `xphp compile` and `xphp check`): - `xphp.alias_cycle` — an alias defined, directly or transitively, in terms of itself (`type A = B; type B = A;`). - - `xphp.alias_arity` — a use whose type-argument count differs from the - alias's parameter count (`type P = …;` used as `P`). + - `xphp.alias_arity` — a use whose type-argument count is outside the + alias's accepted range (`type P = …;` used as `P`; with a + default the range widens — `type P` accepts one or two). + - `xphp.bound_violation` — a use whose argument does not satisfy a + parameter's bound (`type B = …;` used as `B`). - `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. From 34759a37b8af24efe41e2f150dfd52bbab36aa7b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:43:22 +0000 Subject: [PATCH 11/17] fix(monomorphize): drop a file's alias-bound obligations when its parse aborts In check mode, a file that aborts mid-parse (e.g. an arity error) has its AST dropped from the whole-program hierarchy, but any alias-bound obligation already captured during that file's traversal survived in the shared collector. A VALID bounded-alias use earlier in the same file was then verified against a hierarchy missing that file's types, producing a spurious xphp.bound_violation claiming a type that is declared right there "is not in the source set". Buffer each file's obligations in a per-file collector and absorb them into the shared one only after the file parses cleanly, so a failed file's obligations are discarded with its AST. compile() was unaffected (it aborts before the validator runs), but the fix keeps the two paths consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AliasBoundObligationCollector.php | 13 +++++++++++ src/Transpiler/Monomorphize/Compiler.php | 8 ++++++- .../Monomorphize/TypeAliasIntegrationTest.php | 22 ++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php index 6b98262c..ed6f2a55 100644 --- a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -26,6 +26,19 @@ public function add(array $typeParams, array $args, string $label, SourceLocatio $this->obligations[] = new AliasBoundObligation($typeParams, $args, $label, $location); } + /** + * Commit another (per-file) collector's obligations into this one. Used so a file's obligations + * are absorbed only after that file has parsed successfully: a file that aborts mid-parse has its + * AST dropped from the hierarchy, so its obligations — which may reference now-absent types — + * must be dropped with it rather than checked against a hierarchy that no longer contains them. + */ + public function absorb(self $other): void + { + foreach ($other->obligations as $obligation) { + $this->obligations[] = $obligation; + } + } + /** @return list */ public function all(): array { diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index b68e9ad1..949a59a5 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -472,8 +472,14 @@ public function check(FilepathArray $sources): DiagnosticCollector $globalAliases = $this->collectGlobalAliases($contents); $astPerFile = []; foreach ($contents as $filepath => $content) { + // Buffer this file's alias-bound obligations and commit them to the shared collector only + // once the file has parsed cleanly — a file that aborts mid-parse is dropped from the + // hierarchy, so its obligations must not be checked against it (they would reference + // now-absent types and mis-report a valid use as a bound violation). + $fileObligations = new AliasBoundObligationCollector(); try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $aliasBoundObligations); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $fileObligations); + $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 617051f5..6e8a785b 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -405,11 +405,27 @@ public function testABadDefaultOnAnUnusedAliasIsNotChecked(): void // Obligations are captured only where an alias is USED; an alias declared with a default that // would violate its own bound but never instantiated emits nothing (unlike a class template, // which is checked at declaration). Documented divergence, not a bug: an unused alias is inert. - $dist = $this->compile([ + $files = [ 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function unrelated(): int { return 1; } }\n", - ]); + ]; + // No diagnostic of any kind — in particular no bound violation for the (never instantiated) + // bad default — and the unrelated code still compiles. + self::assertFalse($this->check($files)->hasErrors(), 'an unused alias with a bad default is inert'); + self::assertStringContainsString('function unrelated(): int', self::read($this->compile($files), 'C.php')); + } - self::assertStringContainsString('function unrelated(): int', self::read($dist, 'C.php')); + public function testAValidBoundedUseIsNotFalselyReportedWhenTheSameFileAbortsParsing(): void + { + // A file that aborts mid-parse (here on an arity error) is dropped from the hierarchy. A VALID + // bounded-alias use earlier in the same file must NOT then be checked against that missing + // hierarchy — its obligation is discarded with the file, so no spurious bound violation for a + // type ("not in the source set") that is in fact declared right there. + $files = [ + 'A.xphp' => " { public function __construct(public T \$i) {} }\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\ntype B = Bag;\ntype P = Dict;\nfunction ok(): B { return new Bag::(new Widget()); }\nfunction bad(): P { return new Dict::(1, 2); }\n", + ]; + $codes = array_map(static fn ($d): string => $d->code, $this->check($files)->all()); + self::assertContains(XphpSourceParser::CODE_ALIAS_ARITY, $codes, 'the real arity error is still reported'); + self::assertNotContains(Registry::CODE_BOUND_VIOLATION, $codes, 'the valid bounded use must not be falsely flagged'); } public function testUnsupportedAliasBodyIsRejectedInBothModes(): void From 79bcc03ea08de6881daad99ef9897d77db5be181 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 08:32:53 +0000 Subject: [PATCH 12/17] fix(monomorphize): expand a type alias used as a parameter bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parameter bound that names a type alias (`type Named = Face; type B = …`, and likewise `class Box` / a generic method) was resolved by name only, never expanded — so the alias became a phantom class `App\Named` and every argument was rejected as not extending it. Pre-existing for class/method bounds (an xphp.undeclared_type on the phantom); WI-05 exposed it for alias-parameter bounds by enforcing them. Expand an alias leaf in `buildBoundExprNode` exactly as a type position does: a single-head alias becomes that head, a union/nullable alias becomes a union bound (any-of). Reuses the existing expansion (generics, defaults, nested aliases). Guard the newly-reachable recursion — an alias whose own parameter bound refers back to itself — with an in-flight set in `resolveAliasParams`, turning what would be a stack overflow into a clean xphp.alias_cycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 47 ++++++++++- .../Monomorphize/TypeAliasIntegrationTest.php | 80 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index ac0d0cfd..7b568b4b 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -3111,6 +3111,16 @@ public function __construct( */ private array $aliasParamsCache = []; + /** + * Alias FQNs whose parameter resolution has begun. Checked only after {@see $aliasParamsCache} + * misses, so a re-entry recorded here (but not yet cached) is an alias resolving through its + * own parameter bound — a self-referential cycle. Not cleared: once cached, the cache check + * short-circuits before this guard, so a lingering flag never yields a false positive. + * + * @var array + */ + private array $aliasParamsInFlight = []; + // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. public function enterNode(Node $node): ?Node @@ -3970,6 +3980,20 @@ private function buildBoundExprNode(array $node): BoundExpr $fqn = $node['isFq'] ? $node['name'] : $this->resolveNameOnly($node['name']); + // If the bound names a type alias, expand it exactly as a type position would, so + // the check runs against the real type: a single-head alias (`Named = Face`) + // becomes that head, a union / nullable alias (`Num = int|string`) becomes a union + // bound (any-of). Without this the alias name is a phantom class and every argument + // is wrongly rejected. Reaches class-, method-, and alias-parameter bounds alike. + if (isset($this->aliasTable[$fqn])) { + // @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)); + } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); @@ -4243,7 +4267,7 @@ private function captureAliasBoundObligation(string $fqn, array $entry, array $p } } $this->obligations->add( - $this->resolveAliasParams($fqn, $entry), + $this->resolveAliasParams($fqn, $entry, $line), $paddedArgs, "type alias `{$fqn}`", new SourceLocation($this->filepath ?? '', $line), @@ -4283,7 +4307,7 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in XphpSourceParser::CODE_ALIAS_ARITY, ); } - $params = $this->resolveAliasParams($fqn, $entry); + $params = $this->resolveAliasParams($fqn, $entry, $line); $paramNames = array_column($entry['params'], 'name'); $padded = $expandedArgs; for ($i = $given; $i < $total; $i++) { @@ -4332,16 +4356,33 @@ private function resolveAliasBody(string $fqn, array $entry): array * carrying the resolved bound and default. Cached per FQN; feeds both default-padding * (`padAliasArgs`) and bound enforcement (`captureAliasBoundObligation`). * + * 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. + * * @param array{params:list, body:list} $entry * @return list */ - private function resolveAliasParams(string $fqn, array $entry): array + private function resolveAliasParams(string $fqn, array $entry, int $line): array { // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolution is // deterministic for a fixed context, so re-resolving on a cache miss is equivalent. if (isset($this->aliasParamsCache[$fqn])) { return $this->aliasParamsCache[$fqn]; } + if (isset($this->aliasParamsInFlight[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is defined (directly or transitively) in terms of itself.", + $line, + XphpSourceParser::CODE_ALIAS_CYCLE, + ); + } + // @infection-ignore-all TrueValue -- a presence set: the isset() guard above reads key + // existence, not the value, so true vs false is unobservable. Never cleared, and it + // needn't be: the cache check above short-circuits a COMPLETED alias before this guard, + // so a lingering flag can only ever mark an alias still mid-resolution (a real cycle). + $this->aliasParamsInFlight[$fqn] = true; $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map( diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 6e8a785b..91986ba0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -428,6 +428,86 @@ public function testAValidBoundedUseIsNotFalselyReportedWhenTheSameFileAbortsPar self::assertNotContains(Registry::CODE_BOUND_VIOLATION, $codes, 'the valid bounded use must not be falsely flagged'); } + public function testAnAliasUsedAsAParameterBoundIsExpanded(): void + { + // `type Named = Face` used as a bound must check against Face, not a phantom `App\Named`. A + // satisfying argument compiles; a violating one is rejected against the REAL type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a class satisfying the aliased bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Plain()); }\n", + ]; + $collector = $this->check($bad); + self::assertRejected($collector, Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Face"'); + $messages = implode("\n", array_map(static fn ($d): string => $d->message, $collector->all())); + self::assertStringNotContainsString('App\\Named', $messages, 'the bound must name the expanded type, not the alias'); + } + + public function testAnAliasUsedAsAClassParameterBoundIsExpanded(): void + { + // The same expansion fixes the pre-existing class-parameter case (previously an + // xphp.undeclared_type on the phantom alias name): a satisfying arg compiles, a violating one + // is rejected against the real type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\nfunction f(): Box { return new Box::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a class satisfying the aliased class bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\nfunction f(): Box { return new Box::(new Plain()); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Face"'); + } + + public function testAMethodGenericAliasBoundIsExpanded(): void + { + // A generic METHOD's parameter bound also routes through the fix (the GenericMethodCompiler + // path) — an aliased bound satisfied by the argument compiles. + $ok = [ + 'C.xphp' => "(T \$x): T { return \$x; }\n public function call(): void { \$this->m::(new Widget()); }\n}\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a method-generic aliased bound satisfied by the argument compiles'); + } + + public function testAUnionAliasUsedAsABoundIsAnyOf(): void + { + // A union alias `type Either = X|Y` as a bound means "arg is X or Y" (BoundUnion any-of): an + // argument implementing either passes; one implementing neither is rejected against the union. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Either = X|Y;\ntype B = Bag;\nfunction f(): B { return new Bag::(new AX()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'an argument implementing one member of the union bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Either = X|Y;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Neither()); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not satisfy "App\\X | App\\Y"'); + } + + public function testAnAliasToAliasBoundResolvesTransitively(): void + { + // A bound naming an alias whose body is itself an alias resolves through to the real type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype Alias = Named;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'an alias-to-alias bound resolves to the real type'); + } + + public function testASelfReferentialGenericAliasBoundIsRejectedAsACycleNotACrash(): void + { + // A generic alias whose own parameter bound refers back to itself would recurse without bound + // through resolveAliasParams; the in-flight guard turns it into a clean xphp.alias_cycle in + // both modes rather than a stack overflow. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype A = Bag;\nfunction f(): A { return new Bag::(new User()); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear From 5743023bd851e85ae9bbc848204430b565bf6b03 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 08:33:43 +0000 Subject: [PATCH 13/17] docs(type-aliases): note alias-in-bound support and the single-namespace assumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that a parameter bound may name an alias, and add a caveat that a generic alias's body / bound / default resolves in the using file's namespace — correct under one namespace per file (PSR), a mis-resolution risk only in multi-namespace files. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/caveats.md | 7 +++++++ docs/syntax/type-aliases.md | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4157bb4f..aa1ab491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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), like a generic class. A non-generic alias declared in one file is + compile error; the bound may itself name an alias), like a generic class. A non-generic alias declared in one file is usable in another (**cross-file**, whole-program); a generic alias is file-local. A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), diff --git a/docs/caveats.md b/docs/caveats.md index 30594a6c..972980b0 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -121,6 +121,13 @@ Cross-file, an alias colliding with a **class in another file**, or the same ali declared in **two files**, is not detected (both are within one file — `xphp.alias_class_collision` / `xphp.alias_duplicate`). +A generic alias's **body, parameter bounds, and defaults resolve in the file that +*uses* the alias**, not the one that declares it. Under one `namespace {}` per file +(the PSR norm) these are the same, so it never bites; but in a file with multiple +namespace blocks, a bare (non-qualified) name in an alias's body/bound/default is +resolved against the using namespace and can mis-resolve. Keep one namespace per +file, or fully-qualify such names. + ### Why The body is limited to a single head, a flat union, or a nullable because those diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index c140a6c9..b436f3cc 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -75,7 +75,8 @@ no separate code path and no runtime cost. defaulted arguments — `P` fills `B = A = int`), and `type B = Bag;` (a use whose argument does not satisfy the bound is a compile error, the same `xphp.bound_violation` a class - instantiation raises). + instantiation raises). A bound may itself name an alias — `type Named = + Face; type B` checks against `Face`. - **Cross-file**: a **non-generic** alias declared in one file is usable in another of the same build (the whole program shares one alias table). A **generic** alias (one with type parameters) is **file-local** — use it From daec1721a1b894051a3d69b3845e12bd2d81d2fe Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 17:28:58 +0000 Subject: [PATCH 14/17] docs(type-aliases): sync comparison grid, error catalog, and feature indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type aliases shipped but several cross-cutting docs still described them as unshipped or omitted their diagnostics: - comparison feature grid: xphp "Generic type aliases" ❌ → ⚠️ (shipped, with the body-shape and file-local-generic caveats) - error catalog: add the six alias diagnostic codes (cycle, arity, class_collision, duplicate, unsupported_body, compound_in_non_slot) - docs/index and README: move type aliases from "under exploration" / "remaining" to shipped - syntax index: correct the one-line summary (non-generic cross-file, generic file-local; defaults + bounds) - type-bounds: note a bound may name an alias Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +++--- docs/errors.md | 6 ++++++ docs/guides/comparison.md | 2 +- docs/index.md | 6 +++--- docs/syntax/index.md | 2 +- docs/syntax/type-bounds.md | 4 +++- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e778b135..4dfc9323 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generic The object model that's served the ecosystem for two decades doesn't bend easily. -Supporting generics proves that the compile-to-vanilla model handles non-trivial -type-system additions. The remaining features are on +Supporting generics — and now type aliases — proves that the compile-to-vanilla +model handles non-trivial type-system additions. Further features are on the [roadmap](docs/roadmap.md): -type aliases, literal types, mapped and conditional types to name a few. +literal types, mapped and conditional types to name a few. ## Quick start diff --git a/docs/errors.md b/docs/errors.md index 8e333072..4e2d3141 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -56,6 +56,12 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | | `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | | `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line | +| `xphp.alias_cycle` | a [type alias](syntax/type-aliases.md) defined, directly or transitively, in terms of itself — through its body (`type A = B; type B = A;`) or a parameter bound (`type A`) | +| `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) | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | | `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) | diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 0e6404ff..9d947511 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 | ❌ | ❌ | ✅ | ✅ | ✅ | +| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; a generic alias is file-local, and intersection / DNF / closure-signature bodies aren'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 | diff --git a/docs/index.md b/docs/index.md index 1897cf3b..0ff8390d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,6 +57,6 @@ and the gap is explicit in [comparison](guides/comparison.md) and Generics are the first substantial chunk of work in xphp, but the roadmap is much broader. See [roadmap](roadmap.md) for what's -shipped and for the discovery items under exploration (type aliases, -mapped types, variadic generics, generic enums, source maps, AST -macros, and more). +shipped — generics and, now, [type aliases](syntax/type-aliases.md) — +and for the discovery items under exploration (mapped types, variadic +generics, generic enums, source maps, AST macros, and more). diff --git a/docs/syntax/index.md b/docs/syntax/index.md index a95af6f0..ba59f2e8 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 | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution; union/nullable bodies, parameter defaults + bounds; non-generic aliases cross-file, generic aliases file-local | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index b4ff77aa..36502860 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -70,7 +70,9 @@ once it sees `public int $value`. ## Rules -- A bound can be any valid PHP class or interface name. +- A bound can be any valid PHP class or interface name, or a + [type alias](type-aliases.md) that resolves to one (`type Named = Face; + T : Named` checks against `Face`; a union alias becomes a union bound). - Intersection: `T : A & B` — concrete must satisfy both. - Union: `T : A | B` — any operand suffices. - DNF: `T : (A & B) | C` — outer OR of inner ANDs. From b6dfa6bb26123e1a89ea5649c20e498f71553503 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 19:18:08 +0000 Subject: [PATCH 15/17] feat(monomorphize): make type aliases file-local (drop cross-file pre-pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias is a file-local declaration by design, like PHP's own `use` alias — there is no whole-program alias table. Remove WI-03's cross-file machinery: the Compiler's collectGlobalAliases, the parser's aliasTableOf, and the externalAliases parameter threaded through parse / parseWithMap / resolveAndAttach. Expansion now consults only the file's own alias table. This makes generic and non-generic aliases behave consistently (both file-local), and dissolves the two cross-file gaps entirely: a generic alias used in another file surfaces as an undefined template, a non-generic one is simply left unexpanded (flagged by the later PHP/PHPStan pass), and same-file duplicate/collision detection is unchanged. Share a vocabulary by declaring the alias in each file that uses it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 46 ++------------- .../Monomorphize/XphpSourceParser.php | 50 ++++------------- .../Monomorphize/TypeAliasIntegrationTest.php | 56 +++++++++---------- .../compile/type_aliases/source/Consumer.xphp | 13 +++-- .../compile/type_aliases/verify/runtime.php | 4 +- 5 files changed, 51 insertions(+), 118 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 949a59a5..7b040f0a 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -31,8 +31,6 @@ * 4. Emit rewritten user code — rewrite each original source AST (strip generic class defs, * rewrite generic Name references), pretty-print, and write to the target directory. * 5. Persist registry — write .xphp-cache/registry.json. - * - * @phpstan-import-type BoundDict from XphpSourceParser */ final readonly class Compiler { @@ -463,13 +461,11 @@ public function check(FilepathArray $sources): DiagnosticCollector $diagnostics = new DiagnosticCollector(); $aliasBoundObligations = new AliasBoundObligationCollector(); // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a - // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use - // resolves. Only parsing is treated as a per-file, recoverable diagnostic. + // mislabeled "parse error". Only parsing is treated as a per-file, recoverable diagnostic. $contents = []; foreach ($sources->filepaths as $filepath) { $contents[$filepath] = $this->fileReader->read($filepath); } - $globalAliases = $this->collectGlobalAliases($contents); $astPerFile = []; foreach ($contents as $filepath => $content) { // Buffer this file's alias-bound obligations and commit them to the shared collector only @@ -478,7 +474,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // now-absent types and mis-report a valid use as a bound violation). $fileObligations = new AliasBoundObligationCollector(); try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $fileObligations); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $filepath, $fileObligations); $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); @@ -598,48 +594,14 @@ public function check(FilepathArray $sources): DiagnosticCollector */ private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { - $contents = []; - foreach ($sources->filepaths as $filepath) { - $contents[$filepath] = $this->fileReader->read($filepath); - } - $globalAliases = $this->collectGlobalAliases($contents); - $astPerFile = []; - foreach ($contents as $filepath => $content) { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $obligations); + foreach ($sources->filepaths as $filepath) { + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath), $filepath, $obligations); } return $astPerFile; } - /** - * Merge every source's file-local type-alias table into one whole-program table, so an alias - * declared in one file can be used in another. A file whose own aliases are malformed (same-file - * duplicate / collision / unsupported body) raises here and is skipped — the same error - * re-surfaces (and, in check mode, is collected) when that file is parsed for real. - * - * @param array $contents filepath => source - * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> - */ - private function collectGlobalAliases(array $contents): array - { - $global = []; - foreach ($contents as $content) { - try { - foreach ($this->sourceParser->aliasTableOf($content) as $fqn => $entry) { - $global[$fqn] = $entry; - } - } catch (RuntimeException) { - // Any parse-time rejection — skip this file's aliases; the same error re-surfaces (and - // is collected in check mode) when the file is parsed for real. Both a nikic syntax - // error (PhpParser\Error) and an xphp scanner/alias error (XphpParseException) extend - // RuntimeException, so this catches every parse-time failure. - } - } - - return $global; - } - private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 7b568b4b..66a5650b 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -145,43 +145,18 @@ public function __construct(private readonly Parser $parser) } /** - * @param array, body:list}>|null $externalAliases - * a whole-program alias table (from {@see aliasTableOf} across every source) used for - * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). + * A type alias is file-local: only the aliases declared in `$source` are visible to it, mirroring + * PHP's `use`-alias scoping. There is no whole-program alias table. + * * @param ?string $filepath the source file, threaded only so a captured alias-bound obligation * can carry an accurate SourceLocation; null on the standalone parse path. * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations, * verified after the hierarchy is built; null (inert) on the standalone parse path. * @return list */ - public function parse(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array - { - return $this->parseWithMap($source, $externalAliases, $filepath, $obligations)[0]; - } - - /** - * The file-local type-alias table for a single source — its `type` declarations - * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across - * every source into a whole-program table so an alias declared in one file is usable in another. - * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via - * the main parse; the caller catches and skips a file that raises one here. - * - * @return array, body:list}> - */ - public function aliasTableOf(string $source): array + public function parse(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - [, , , $cleaned, $byteOffsetMap, , $aliasMarkers] = $this->scanAndStrip($source); - // @infection-ignore-all ReturnRemoval -- optimization: an alias-free file (the common case) - // skips the re-parse; without it buildAliasTable([]) returns [] anyway. - if ($aliasMarkers === []) { - return []; - } - $ast = $this->parser->parse($cleaned); - if ($ast === null) { - return []; - } - /** @var list $ast — nikic's parse() returns array; keys are always 0..N-1. */ - return self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + return $this->parseWithMap($source, $filepath, $obligations)[0]; } /** @@ -193,11 +168,10 @@ public function aliasTableOf(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * - * @param array, body:list}>|null $externalAliases * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array + public function parseWithMap(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -217,7 +191,7 @@ public function parseWithMap(string $source, ?array $externalAliases = null, ?st } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases, $filepath, $obligations); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $filepath, $obligations); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3046,16 +3020,14 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $methodMarkers * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @param array, body:list}>|null $externalAliases * @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, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string { // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / - // unsupported body) regardless; a whole-program table, when injected, is what expansion - // actually looks aliases up in so a use can reach an alias declared in another file. - $fileTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); - $aliasTable = $externalAliases ?? $fileTable; + // unsupported body). A type alias is file-local, so this file's own table is the only one + // expansion consults — an alias declared in another file is simply not visible here. + $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); $traverser = new NodeTraverser(); $visitor = new /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 91986ba0..79a0a249 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -205,40 +205,38 @@ public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void $this->assertCompileThrows($files, 'unsupported body'); } - public function testAliasesAreVisibleAcrossFilesInTheSameBuild(): void - { - // Whole-program alias table: an alias declared in one file is usable in another (union and - // plain-class bodies both). - $dist = $this->compile([ - 'Types.xphp' => " "compile([ + 'Types.xphp' => " " " " " { public function __construct(public K \$k, public V \$v) {} }\ntype Pair = Dict;\n", + 'Consumer.xphp' => " { return new Dict::(1, 'x'); }\n", ]; - self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + self::assertRejected($this->check($generic), 'xphp.undefined_template', 'App\\Pair'); } - public function testAliasFileWithASyntaxErrorIsCollectedNotCrashed(): void + public function testRedeclaringAnAliasPerFileSharesItAsFileLocal(): void { - // The pre-pass re-parses an alias-bearing file to collect its aliases; a nikic SYNTAX error - // there (a PhpParserError, not an xphp RuntimeException) must be caught/skipped too, so check - // collects it for real rather than crashing the whole-program alias collection. - $files = [ - 'Broken.xphp' => "check($files)->hasErrors(), 'a syntax error in an alias file is collected, not crashed'); + // The share-a-vocabulary pattern under file-local scoping: declare the alias in each file that + // uses it (a zero-cost substitution). The target class is a normal cross-file class reference. + $dist = $this->compile([ + 'Types.xphp' => " "widen('cross'); +$localAlias = new LocalAlias(); +$localValue = $localAlias->widen('local'); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index 694918b0..1918210b 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -41,6 +41,6 @@ Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); - // Cross-file: Consumer.xphp used `Num` declared in Types.xphp. - Assert::assertSame('cross', $crossValue, 'an alias declared in Types.xphp was usable in Consumer.xphp'); + // File-local: Consumer.xphp declares its OWN `Num` and it expands independently of Types.xphp. + Assert::assertSame('local', $localValue, 'a file-local alias in a second file expands there'); }; From fef5546a206ce85543d525507d1416040a429991 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 19:22:08 +0000 Subject: [PATCH 16/17] docs(type-aliases): reframe file-locality as by-design, not a limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type aliases are now file-local for all cases (WI-07). Rewrite the docs to present file-locality as an intentional design choice — an alias is a local naming convenience like a `use` alias, not a whole-program symbol — rather than a "safe subset first" limitation: caveats, syntax tour + index, roadmap (timeline + shipped), the comparison grid caveat, CHANGELOG, and ADR-0023's delivered-scope note. Drop the cross-file duplicate/collision "not detected" notes (moot — per-file scoping has nothing to detect across files). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- .../adr/0023-type-alias-declaration-syntax.md | 3 +- docs/caveats.md | 51 ++++++++++--------- docs/guides/comparison.md | 2 +- docs/roadmap.md | 8 +-- docs/syntax/index.md | 2 +- docs/syntax/type-aliases.md | 17 ++++--- 7 files changed, 47 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1ab491..5f09a033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. A non-generic alias declared in one file is - usable in another (**cross-file**, whole-program); a generic alias is file-local. + 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), diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md index dc146c38..66479adc 100644 --- a/docs/adr/0023-type-alias-declaration-syntax.md +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -61,7 +61,8 @@ 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, cross-file; +- 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. diff --git a/docs/caveats.md b/docs/caveats.md index 972980b0..b44268a6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -91,10 +91,11 @@ behavior, only makes the type explicit. ## Type-alias body and position limits -[Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single -head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body -are all supported; parameters may carry defaults and bounds; and a **non-generic** -alias declared in one file is usable in another. Three limits remain. +[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. ### ❌ What doesn't work @@ -109,24 +110,31 @@ 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 +``` + +### 🔒 File-local (by design) -// A GENERIC alias (one with type parameters) is file-local: +An alias is scoped to its file, like a `use` alias — not visible in another file: + +```php // File Types.xphp +type UserId = Ident; type Pair = Dict; // File Other.xphp — a DIFFERENT file -function f(): Pair { /* … */ } // ✗ Pair is not visible here (generic alias is file-local) +function f(): UserId { … } // UserId is a plain unknown type here — not expanded +function g(): Pair { … } // ✗ Pair is not visible — an undefined template ``` -Cross-file, an alias colliding with a **class in another file**, or the same alias -declared in **two files**, is not detected (both are within one file — -`xphp.alias_class_collision` / `xphp.alias_duplicate`). +To share a vocabulary, **declare the alias in each file that uses it** (a zero-cost +substitution) or reference the underlying type directly. Because scoping is +per-file there is no cross-file duplicate or collision to detect — two files each +with `type Id = …` are simply independent local aliases. (Same-file duplicate / +class-collision *are* caught — `xphp.alias_duplicate` / `xphp.alias_class_collision`.) -A generic alias's **body, parameter bounds, and defaults resolve in the file that -*uses* the alias**, not the one that declares it. Under one `namespace {}` per file -(the PSR norm) these are the same, so it never bites; but in a file with multiple -namespace blocks, a bare (non-qualified) name in an alias's body/bound/default is -resolved against the using namespace and can mis-resolve. Keep one namespace per -file, or fully-qualify such names. +An alias's body, bounds, and defaults resolve in the namespace that **uses** it. +Under one `namespace {}` per file (the PSR norm) that is always the declaring +namespace; in a file with multiple namespace blocks a bare name can mis-resolve — +keep one namespace per file, or fully-qualify. ### Why @@ -135,12 +143,9 @@ lower cleanly into a PHP type node. An intersection or DNF pulls in *distributio (`(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. Cross-file expansion is a whole-program pre-pass that merges each -file's alias table; a *generic* alias use is expanded before that table is -consulted for arguments, so a generic alias resolves only within its own file -(and its declared parameter bounds are enforced there). Global duplicate/collision -checking across the merge is a later refinement. These are "make the safe subset -solid first" trades, not permanent design limits. +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. ### ✅ Workaround @@ -148,8 +153,8 @@ solid first" trades, not permanent design limits. a named class or interface and alias *that*. - 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 a generic alias in each file that uses it (a zero-cost substitution), or - reference the underlying generic type directly across files. +- Declare an alias in each file that uses it (a zero-cost substitution), or + reference the underlying type directly across files. --- diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 9d947511..de325cbe 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; a generic alias is file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | +| 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) | ❌ | ✅ | ✅ | ✅ | | 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 85075c72..ba4e2ecf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,9 +51,9 @@ timeline : runtime instanceof T : marker interface per template Type aliases - : compile-time substitution + : compile-time substitution, file-local : single-head union and nullable bodies - : whole-program cross-file use + : parameter defaults and bounds Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -234,8 +234,8 @@ upcoming one. - 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. -- **Cross-file**: a non-generic alias declared in one file is usable in - another (whole-program alias table); a generic alias is file-local. +- **File-local by design**: an alias is visible only in the file that + declares it (like a `use` alias); declare it per file to share it. - Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body (intersection / DNF / closure), compound-in-non-slot, and bound-violating uses are loud compile errors in both `compile` and diff --git a/docs/syntax/index.md b/docs/syntax/index.md index ba59f2e8..ed8c1b06 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; union/nullable bodies, parameter defaults + bounds; non-generic aliases cross-file, generic aliases file-local | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local; union/nullable bodies, parameter defaults + bounds | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index b436f3cc..5ba346ed 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -77,10 +77,10 @@ no separate code path and no runtime cost. bound is a compile error, the same `xphp.bound_violation` a class instantiation raises). A bound may itself name an alias — `type Named = Face; type B` checks against `Face`. -- **Cross-file**: a **non-generic** alias declared in one file is usable in - another of the same build (the whole program shares one alias table). A - **generic** alias (one with type parameters) is **file-local** — use it - in the file that declares it (see caveats). +- **File-local**: an alias is visible only in the file that declares it, + like a PHP `use` alias. To share a vocabulary, declare the alias in each + file that uses it (a zero-cost substitution), or reference the underlying + type directly (see caveats). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -105,20 +105,21 @@ no separate code path and no runtime cost. ## Caveats -Union and nullable bodies and cross-file use all work; the remaining +An alias is **file-local by design** (like a `use` alias). The remaining limits are the body shape and the positions a compound alias can take. See [caveats → type-alias body and position limits](../caveats.md#type-alias-body-and-position-limits) for the details and the reasons: +- **File-local.** An alias is visible only in its own file — declare it in + 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`. -- **Cross-file collision / duplicate not detected.** An alias colliding - with a class, or the same alias declared, in a *different* file is not - flagged (both are within one file). ## See also From 1c4694f94a66040e19d4c71780f54990fc0272e3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 2 Aug 2026 18:10:57 +0000 Subject: [PATCH 17/17] fix(monomorphize): correct alias-body message + guard argument-path cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the type-alias feature: - The `xphp.alias_unsupported_body` message listed unions and nullables as unsupported, but both are supported — it now names only intersection, DNF, and closure-signature bodies. - A self-referential alias whose cycle runs through a generic argument of a non-alias class (`type A = Bag>`) bypassed the cycle guard and recursed without bound; argument expansion now carries the same visited chain as the body, so it is a clean `xphp.alias_cycle`. - Refresh two stale docblocks that said duplicate/cycle/arity diagnostics "land in a later change" — all are implemented. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 22 +++++++++++-------- .../Monomorphize/TypeAliasIntegrationTest.php | 16 ++++++++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 66a5650b..2111818f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2897,8 +2897,9 @@ private static function applyReplacements(string $source, array $replacements): * namespace is found by locating the `Namespace_` node whose (original-source) byte span contains * the `type` keyword, so a real class sharing an alias's short name in another namespace never * collides. Bodies stay raw (unresolved) — they resolve lazily at expansion, when the use-site - * namespace context is available. A duplicate FQN keeps the last declaration (a dedicated - * duplicate-alias diagnostic lands in a later change). + * namespace context is available. A duplicate FQN is rejected with `xphp.alias_duplicate` (never + * silently overwritten), and a name colliding with a class/interface/trait with + * `xphp.alias_class_collision`. * * @param list $ast * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers @@ -2943,8 +2944,8 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff if ($marker['body'] === null) { throw new XphpParseException( "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " - . 'or generic type (unions, intersections, nullables, and closure signatures are ' - . 'not supported). Use a bare type or a named class.', + . 'or generic type, a union, or a nullable (intersection, DNF, and closure-signature ' + . 'bodies are not supported). Use a bare type or a named class.', $marker['line'], self::CODE_ALIAS_UNSUPPORTED_BODY, ); @@ -4140,10 +4141,10 @@ private static function unionMembersToNode(array $members, array $attrs): Node * 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 * body (params → arguments) and re-expanded, so nested and concrete-instantiation aliases - * (`type UserMap = Pair`) resolve fully. A head that recurs into itself is a - * cycle, and a use whose argument count differs from the alias's parameter count is an - * arity error — both fail loudly (refined into `xphp.alias_cycle` / `xphp.alias_arity` - * diagnostics in a later change). + * (`type UserMap = Pair`) resolve fully. A head that recurs into itself + * (through its body or a generic argument) is a cycle, and a use whose argument count + * differs from the alias's parameter count is an arity error — both fail loudly with + * `xphp.alias_cycle` / `xphp.alias_arity`. * * @param list $visited alias FQNs already entered on this expansion chain */ @@ -4177,7 +4178,10 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef */ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array { - $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $line), $ref->args); + // 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 + // argument path; passing an empty chain here would miss it and recurse without bound. + $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)]; diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 79a0a249..5f8ed309 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -250,6 +250,18 @@ public function testCyclicAliasIsRejectedInBothModes(): void $this->assertCompileThrows($files, 'in terms of itself'); } + public function testCycleThroughAGenericArgumentIsRejectedNotACrash(): void + { + // The cycle passes through the generic ARGUMENT of a non-alias class (`Bag>`), not the + // head. Argument expansion must carry the same visited chain as the body, so this is a clean + // xphp.alias_cycle in both modes rather than unbounded recursion / a stack overflow. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype A = Bag>;\nfunction f(): A { throw new \\Exception(); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + public function testAliasArityMismatchIsRejectedInBothModes(): void { $files = [ @@ -514,8 +526,8 @@ public function testUnsupportedAliasBodyIsRejectedInBothModes(): void $files = [ 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); $this->assertCompileThrows($files, $message); }