diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d67b2..5f09a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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`); parameters carry + **defaults** (`type P` — a use may omit trailing defaulted arguments) + and **bounds** (`type B` — an argument that violates the bound is a + compile error; the bound may itself name an alias), like a generic class. An alias + is **file-local** — visible only in the file that declares it, like a `use` alias. + A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), + class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), + unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), + compound-in-non-slot (`xphp.alias_compound_in_non_slot`), or bound-violating + (`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/README.md b/README.md index e778b13..4dfc932 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/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md new file mode 100644 index 0000000..66479ad --- /dev/null +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -0,0 +1,115 @@ +# 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: 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. + +### 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-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). +- [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 c0d5bcd..b4216ff 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 a261238..b44268a 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -89,6 +89,75 @@ 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-alias body and position limits + +[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 + +```php +type Both = A & B; // ✗ xphp.alias_unsupported_body — intersection +type Dnf = (A & B) | C; // ✗ xphp.alias_unsupported_body — DNF +type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature + +// A union / nullable alias is only usable as the WHOLE type of a slot: +type 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 +``` + +### 🔒 File-local (by design) + +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(): UserId { … } // UserId is a plain unknown type here — not expanded +function g(): Pair { … } // ✗ Pair is not visible — an undefined template +``` + +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`.) + +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 + +The body is limited to a single head, a flat union, or a nullable because those +lower cleanly into a PHP type node. An intersection or DNF pulls in *distribution* +(`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or +anchor, so it is representable only as the whole type of a param / property / +return / class-constant slot — anywhere else it is rejected loudly rather than +mis-compiled. These are "make the safe subset solid first" trades, candidates to +lift later. File-locality, by contrast, is a deliberate choice — an alias is a +local naming convenience, like `use`, not a whole-program symbol — not a limit. + +### ✅ Workaround + +- 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. +- Declare an alias in each file that uses it (a zero-cost substitution), or + reference the underlying type directly across files. + +--- + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work diff --git a/docs/errors.md b/docs/errors.md index 8e33307..4e2d314 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 0e6404f..de325cb 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; 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/index.md b/docs/index.md index 1897cf3..0ff8390 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/roadmap.md b/docs/roadmap.md index b5e3295..ba4e2ec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,6 +50,10 @@ timeline Reified T : runtime instanceof T : marker interface per template + Type aliases + : compile-time substitution, file-local + : single-head union and nullable bodies + : parameter defaults and bounds Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -61,7 +65,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 +222,27 @@ 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). +- 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. +- 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. +- **File-local by design**: an alias is visible only in the file that + declares it (like a `use` alias); declare it per file to share it. +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body + (intersection / DNF / closure), compound-in-non-slot, and + bound-violating uses are loud compile errors in both `compile` and + `check`, each with a stable code. +- See the [type aliases](syntax/type-aliases.md) tour and the + [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). + ### Naming and collisions - SHA-256-based generated FQCN; namespace mirrors the template. @@ -264,7 +288,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 cfc6545..ed8c1b0 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; 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 new file mode 100644 index 0000000..5ba346e --- /dev/null +++ b/docs/syntax/type-aliases.md @@ -0,0 +1,128 @@ +# 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 +type Num = int|string; // union body +type MaybeUser = ?User; // nullable body +``` + +## 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 + +- **Declaration forms**: `type Name = Body;` (generic) and + `type Name = Body;` (non-generic). The parameter list is optional; the + separator is `=`. +- **Bodies**: a single (possibly-generic) head (`Ident`, `Dict`), a + **union** (`int|string`), or a **nullable** (`?Box`). A single-head or + generic body expands in **every** type position, including as a generic + argument (`Bag`), `new`, `extends`, and a bound. A **union / + nullable** body expands only as the *whole* type of a parameter, + property, return, or class-constant slot (see caveats). +- **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). A bound may itself name an alias — `type Named = + Face; type B` checks against `Face`. +- **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>`). +- 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 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. + - `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 + +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`. + +## See also + +- Test fixture: `test/fixture/compile/type_aliases/` +- Related: [classes and interfaces](classes-and-interfaces.md), + [turbofish](turbofish.md) diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index b4ff77a..3650286 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. diff --git a/src/Transpiler/Monomorphize/AliasBoundObligation.php b/src/Transpiler/Monomorphize/AliasBoundObligation.php new file mode 100644 index 0000000..ec5d91e --- /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 0000000..ed6f2a5 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -0,0 +1,47 @@ + */ + 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); + } + + /** + * 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 + { + return $this->obligations; + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundValidator.php b/src/Transpiler/Monomorphize/AliasBoundValidator.php new file mode 100644 index 0000000..584f671 --- /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 23e8e60..7b040f0 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -67,7 +67,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); @@ -124,6 +125,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 @@ -455,13 +459,23 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); - $astPerFile = []; + $aliasBoundObligations = new AliasBoundObligationCollector(); + // Read every source up front — 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. + $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); + } + $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); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $filepath, $fileObligations); + $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -478,11 +492,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 @@ -520,6 +535,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 @@ -574,11 +592,11 @@ public function check(FilepathArray $sources): DiagnosticCollector * * @return array> */ - private function parseAll(FilepathArray $sources): array + private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { $astPerFile = []; foreach ($sources->filepaths as $filepath) { - $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath), $filepath, $obligations); } return $astPerFile; diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6e55b18..67e710f 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/XphpParseException.php b/src/Transpiler/Monomorphize/XphpParseException.php index e22152e..b743d0f 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 bf5c9e1..2111818 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 @@ -110,6 +111,19 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + // 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 * unambiguously a builtin, so every site that asks "is this name a builtin keyword or a class?" @@ -131,11 +145,18 @@ public function __construct(private readonly Parser $parser) } /** + * 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 + public function parse(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - return $this->parseWithMap($source)[0]; + return $this->parseWithMap($source, $filepath, $obligations)[0]; } /** @@ -147,11 +168,12 @@ 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 ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source): array + public function parseWithMap(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): 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 +191,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, $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 @@ -273,7 +295,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 +304,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 +329,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:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -321,6 +343,8 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ + $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -328,6 +352,27 @@ 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') { + $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; + $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 @@ -828,7 +873,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]; } /** @@ -2385,6 +2430,177 @@ 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 `[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 + * 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 + * @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 + { + // 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. 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) { + return null; + } + if ($afterNameTok->text === '<') { + $parsed = self::parseTypeParamList($tokens, $afterName, allowDefaults: true, allowVariance: true); + if ($parsed === null) { + return null; + } + [$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. + $eqIdx = self::skipWs($tokens, $paramsEndIdx + 1); + } else { + $eqIdx = $afterName; + } + + // `=`. + if (($tokens[$eqIdx] ?? null)?->text !== '=') { + return 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; + } + + // 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 [ + [ + 'name' => $nameTok->text, + 'params' => $params, + 'body' => $body, + 'bytePosition' => $tokens[$typeIdx]->pos, + 'line' => $tokens[$typeIdx]->line, + ], + $semiIdx, + ]; + } + + /** + * 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). + * 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 > )?`. * @@ -2676,6 +2892,114 @@ 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 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 + * @return array, body:list}> + */ + 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()), + ]; + } + } + $classFqns = self::collectClassLikeFqns($ast); + $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']; + 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, 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, + ); + } + 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] = ['params' => $marker['params'], '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. * @@ -2696,15 +3020,21 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): ?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). 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 /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap) 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 = []; @@ -2719,6 +3049,10 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers + * @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, @@ -2726,11 +3060,43 @@ public function __construct( private array $methodMarkers, private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, + private array $aliasTable, + private ?string $filepath, + private ?AliasBoundObligationCollector $obligations, ) { $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 = []; + + /** + * 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> + */ + 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 { if ($node instanceof Use_ || $node instanceof GroupUse) { // Reject a generic clause on a namespace-import BEFORE the blanket @@ -3055,6 +3421,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 @@ -3065,7 +3441,9 @@ public function enterNode(Node $node): null // 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); } @@ -3091,17 +3469,17 @@ public function enterNode(Node $node): null $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; @@ -3112,16 +3490,22 @@ public function enterNode(Node $node): null * 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); } } } @@ -3569,6 +3953,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)); @@ -3637,6 +4035,368 @@ 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. 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); + $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], + ); + 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); + } + + /** + * 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 + * (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 + */ + 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 + { + // 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)]; + } + if (in_array($ref->name, $visited, true)) { + throw new XphpParseException( + "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", + $line, + XphpSourceParser::CODE_ALIAS_CYCLE, + ); + } + $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]; + } + $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; + } + + /** + * 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, $line), + $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 + * 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, + ); + } + $params = $this->resolveAliasParams($fqn, $entry, $line); + $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 $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; + } + + /** + * 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{params:list, body:list} $entry + * @return list + */ + 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 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[] = 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 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`). + * + * 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, 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( + fn (array $param): TypeParam => new TypeParam( + $param['name'], + $this->buildBoundExpr($param), + $this->buildDefault($param), + $param['variance'], + ), + $entry['params'], + ); + $this->typeParamStack = $saved; + return $this->aliasParamsCache[$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 0000000..5f8ed30 --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -0,0 +1,709 @@ +] = 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' => "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 testAnAliasIsFileLocalAndNotVisibleInAnotherFile(): void + { + // A type alias is file-local, like a `use` alias — an alias declared in one file is NOT + // visible in another. A non-generic use is left unexpanded (a bare reference the later + // PHP/PHPStan pass would flag); a generic use surfaces loudly as an undefined template. + $nonGeneric = $this->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($generic), 'xphp.undefined_template', 'App\\Pair'); + } + + public function testRedeclaringAnAliasPerFileSharesItAsFileLocal(): void + { + // 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' => " " " = 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), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $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 = [ + '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 2 type argument(s), 1 given'); + $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 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. + $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')); + } + + 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 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 + // 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' => "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)); + } + + /** @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()); + } + } + + /** + * 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' + { 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) ?: '') : ''; + } + + /** + * 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)) { + 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/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b35b9d2..4c58776 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -85,6 +85,171 @@ 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 + } + + /** + * Every recognized alias body — a single head, a union, a nullable, and even an unsupported + * intersection — is stripped at scan (so `strip()` never produces a raw PHP parse error; an + * unsupported body's diagnostic is raised later at parse time). A reserved-word head is not a + * recognized alias at all and is left byte-for-byte intact. + */ + public function testRecognizedAliasBodiesAreStrippedWhileReservedNameIsDeclined(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $union = "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. + $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 +1207,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_ { 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 0000000..9fea8fd --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Consumer.xphp @@ -0,0 +1,17 @@ +widen('local'); 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 0000000..af5ae7d --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -0,0 +1,61 @@ + = 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 {} + +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(); + } + + // 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. +$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()); +// 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 new file mode 100644 index 0000000..1918210 --- /dev/null +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -0,0 +1,46 @@ + = 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'; + 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>'); + + // 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'); + + // 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'); + + // 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'); +};