feat(type-aliases): file-local generic type aliases via compile-time substitution - #32
feat(type-aliases): file-local generic type aliases via compile-time substitution#32math3usmartins wants to merge 17 commits into
Conversation
|
| Filename | Overview |
|---|---|
| src/Transpiler/Monomorphize/XphpSourceParser.php | Core of the PR: adds ~750 lines for alias scanning, table-building, and AST-level expansion. Cycle detection correctly threads $visited through argument expansion (addressing the prior argument-path cycle concern). Two separate cycle-detection mechanisms (visited chain for body-path, inFlight flag for bound-path) are both sound. PHP keyword types array/callable silently fail as alias bodies due to isNameToken token-ID filtering. |
| src/Transpiler/Monomorphize/AliasBoundObligation.php | New value object capturing a deferred alias-parameter bound obligation (typeParams + padded args + label + location). Simple and correct. |
| src/Transpiler/Monomorphize/AliasBoundObligationCollector.php | New collector that accumulates obligations and provides absorb() for per-file isolation — a failed file's obligations are dropped rather than validated against a hierarchy that no longer contains its types. |
| src/Transpiler/Monomorphize/AliasBoundValidator.php | New stateless validator that iterates obligations and delegates to Registry::checkAliasBounds, reusing the identical bound-check seam as generic classes. |
| src/Transpiler/Monomorphize/Compiler.php | Threads AliasBoundObligationCollector through both compile() and check() pipelines, calling AliasBoundValidator::validate after the hierarchy is built. Per-file obligation isolation in check() is correctly implemented. |
| src/Transpiler/Monomorphize/Registry.php | Adds checkAliasBounds static method — a thin wrapper around the existing checkBounds/groundSiblingBounds machinery so alias-parameter bound errors surface as identical xphp.bound_violation diagnostics. |
| src/Transpiler/Monomorphize/XphpParseException.php | Minor addition: optional stable diagnostic code attached to the exception, enabling check-mode to report alias-specific codes (e.g. xphp.alias_cycle) rather than the generic parse-error code. |
| test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php | 709-line integration test suite covering all supported alias forms (generic, non-generic, concrete-instantiation, union body, nullable body, transitive alias), all six error codes, both compile and check modes, and the argument-path cycle variant (the previously-flagged P1 case). |
| test/fixture/compile/type_aliases/source/Types.xphp | Runtime fixture exercising generic, non-generic, concrete-instantiation, union-body, and nullable-body aliases with real specializations; verifiable via runtime.php. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Source .xphp file"] --> B["scanAndStrip()\nDetect & blank 'type Name = Body;'\nRecord aliasMarkers"]
B --> C["nikic/php-parser\nParse cleaned source -> AST"]
C --> D["buildAliasTable()\nFQN -> {params, body}\nReject: duplicate / class-collision / null body"]
D --> E["resolveAndAttach() traversal\nleaveNode(Name)"]
E --> F{"Name in\naliasTable?"}
F -->|No| G["Leave node unchanged"]
F -->|Yes| H["expandAliasToUnion(ref, visited, line)"]
H --> I{"Visited\ncycle?"}
I -->|Yes| J["Throw alias_cycle"]
I -->|No| K["padAliasArgs -> resolveAliasBody\ncaptureAliasBoundObligation"]
K --> L{"union\nmembers?"}
L -->|1 member| M["Return single TypeRef node"]
L -->|2+ members| N{"ATTR_ALIAS_WHOLE_SLOT\nset?"}
N -->|Yes| O["Build UnionType / NullableType node"]
N -->|No| P["Throw alias_compound_in_non_slot"]
K --> Q["Expand body members recursively\nwith updated visited chain"]
Q --> H
R["AliasBoundObligationCollector\nper-file obligations"] --> S["AliasBoundValidator.validate()\nafter TypeHierarchy built"]
S --> T{"Bound\nviolated?"}
T -->|compile| U["Throw bound_violation"]
T -->|check| V["Collect diagnostic"]
Reviews (2): Last reviewed commit: "fix(monomorphize): correct alias-body me..." | Re-trigger Greptile
Recognize a `type Name[<A, B>] = SingleHeadBody;` declaration in the scanner and blank it to equal-length whitespace, so the (otherwise invalid) statement never reaches the host PHP parser. The alias arm runs first — ahead of the bare `Name<…>` arm — so the `<A, B>` clauses on the alias head and its body are not half-stripped. This is the recognition + strip step only: the alias body is not yet captured or expanded (that follows). Statement-position gated so `type` used as a constant, function, or member name is never mistaken for a declaration. Single-head bodies only; a union / intersection / nullable body, or any other non-single-head shape, declines here and falls through (to become an explicit diagnostic in a later change). The separator must be `=`; the param list is parsed permissively. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture each `type Name[<…>] = SingleHead;` declaration and expand its uses into the alias body before specialization, so nothing downstream (registry, specializer, call-site rewriter) ever sees an alias and the emitted PHP contains no alias name. - Build a file-local alias table keyed by FQN, attributing each alias to its declaring namespace by byte span (a real class sharing an alias's short name in another namespace never collides). - Expand in the resolver's Name branch: the head AND, recursively, the arguments (an alias can appear as a generic argument, e.g. Bag<Elem>), substituting parameters via the resolved body. Nested and concrete-instantiation aliases (UserMap = Pair<int, User>) resolve fully; a non-alias name is left untouched. - Reject a self-referential (cyclic) or arity-mismatched alias loudly in both modes: compile throws, check collects the diagnostic. Aliases are a pure compile-time substitution with no runtime existence; v1 is file-local and single-head-bodied. A runtime fixture executes the compiled output and asserts every alias name is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give each type-alias rejection a stable code and raise the two that were previously silent or unclear: - xphp.alias_class_collision — an alias whose FQN matches a class / interface / trait declared in the same file is now a loud error, not a silent shadow of that class. - xphp.alias_unsupported_body — a union / intersection / nullable / closure body is recognized and stripped (so `strip()` never emits a raw PHP parse error) and rejected with a clear message at parse time. - xphp.alias_duplicate — the same alias FQN declared twice in a file. - xphp.alias_cycle / xphp.alias_arity — promoted from the generic parse-error code to dedicated codes. XphpParseException carries an optional diagnostic code; check maps it onto the collected diagnostic (compile still throws). Every rejection is verified in both modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New syntax tour page (docs/syntax/type-aliases.md) + index row. - Caveat covering the v1 boundaries (file-local, single-head bodies, same-file collision detection) and their reasons. - Roadmap: move type aliases from Discovery to Shipped. - ADR-0023: the declaration-form syntax decision (`type Name<…> = Body`) and compile-time-substitution model, with the alternatives weighed. - CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the single-head-only restriction for union (`A|B|…`) and nullable (`?X`) alias bodies, which expand into a real PHP `UnionType` / `NullableType` where a slot can hold one. `?X` desugars to `X|null`; a three-member `A|B|null` stays a `UnionType`, while `?X` (one non-null, atomic) emits `?X` (`?(A&B)` would be a fatal parse error). A compound (union) alias is only representable as the WHOLE type of a param / property / return / class-const slot — threaded via a wholeSlot flag from markType. As a generic argument, in `new` / turbofish / `extends` / a bound, or nested inside another nullable/union at the use site, it is rejected loudly (`xphp.alias_compound_in_non_slot`) in both modes. Intersection, DNF, and closure-signature bodies remain `xphp.alias_unsupported_body` (a later change). A single-head alias that transitively resolves to a union expands as a union too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make an alias declared in one file usable in another. The Compiler runs a pre-pass over every source, merging each file's local alias table (XphpSourceParser::aliasTableOf) into one whole-program table, then injects it into the per-file parse so expansion resolves an alias no matter which file declares it. A file whose own aliases are malformed is skipped in the pre-pass; the same rejection re-surfaces (and is collected in check mode) when that file is parsed for real. A standalone parse (the LSP / tolerant path) keeps aliases file-local — the whole-program table is a compile/check concern. Same-file duplicate / collision / unsupported-body checks are unchanged; cross-file duplicate and collision are last-wins / undetected (a later refinement). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the type-alias docs to the delivered feature: union and nullable bodies and cross-file (whole-program) use. Rewrite the caveat (renamed to body/position limits — file-local and single-head no longer apply) and repoint the syntax/roadmap/ADR anchors; add the `xphp.alias_compound_in_non_slot` code; refresh the roadmap Shipped entry, the ADR consequences, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type alias's own parameters may declare defaults (`type P<A, B = A> = Dict<A, B>;`), which `parseTypeParamList` already parses but expansion discarded — using fewer args than params was a flat `xphp.alias_arity`. Retain the full per-param entries in the alias marker/table, resolve each default against the alias's params (so `B = A` and chained `C = B` fill from earlier arguments), and pad missing trailing arguments at expansion. The valid arity is now `required <= given <= total`; the message keeps the exact `expects N` form with no defaults and uses `between R and N` only when defaults make the count a range. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type alias's parameters may declare bounds (`type B<T : Named> = Bag<T>;`), previously parsed and dropped. Enforce them: because alias expansion runs per file before the whole-program hierarchy exists, each used bounded alias records an AliasBoundObligation (its resolved parameters + concrete padded arguments + use-site location), collected across files and verified once the hierarchy is built by AliasBoundValidator via a new Registry::checkAliasBounds — the same check a class instantiation runs, so a violation surfaces as an identical xphp.bound_violation (thrown in compile, collected in check). An argument whose top level is a type parameter (`B<X>` inside `class C<X>`) is skipped (absent from the hierarchy, it would be spuriously rejected); a concrete head over a type-param inner (`Coll<X>`) is checked, since bounds erase generic arguments. Only file-local generic aliases reach enforcement — a cross-file generic-alias use is a separate unsupported case that hard-errors as an undefined template — so a captured bound always resolves in the context it was declared, with no cross-file misresolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the two new generic-alias parameter capabilities: defaults (`type P<A, B = A>`, trailing arguments may be omitted) and enforced bounds (`type B<T : Named>`, a violating argument is xphp.bound_violation). Update the syntax tour's Rules, the roadmap Shipped entry, and the CHANGELOG, and note in the caveat that a generic alias is file-local (a non-generic alias is cross-file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se aborts In check mode, a file that aborts mid-parse (e.g. an arity error) has its AST dropped from the whole-program hierarchy, but any alias-bound obligation already captured during that file's traversal survived in the shared collector. A VALID bounded-alias use earlier in the same file was then verified against a hierarchy missing that file's types, producing a spurious xphp.bound_violation claiming a type that is declared right there "is not in the source set". Buffer each file's obligations in a per-file collector and absorb them into the shared one only after the file parses cleanly, so a failed file's obligations are discarded with its AST. compile() was unaffected (it aborts before the validator runs), but the fix keeps the two paths consistent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A parameter bound that names a type alias (`type Named = Face; type B<T : Named> = …`, and likewise `class Box<T : Named>` / a generic method) was resolved by name only, never expanded — so the alias became a phantom class `App\Named` and every argument was rejected as not extending it. Pre-existing for class/method bounds (an xphp.undeclared_type on the phantom); WI-05 exposed it for alias-parameter bounds by enforcing them. Expand an alias leaf in `buildBoundExprNode` exactly as a type position does: a single-head alias becomes that head, a union/nullable alias becomes a union bound (any-of). Reuses the existing expansion (generics, defaults, nested aliases). Guard the newly-reachable recursion — an alias whose own parameter bound refers back to itself — with an in-flight set in `resolveAliasParams`, turning what would be a stack overflow into a clean xphp.alias_cycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ace assumption Record that a parameter bound may name an alias, and add a caveat that a generic alias's body / bound / default resolves in the using file's namespace — correct under one namespace per file (PSR), a mis-resolution risk only in multi-namespace files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…indexes Type aliases shipped but several cross-cutting docs still described them as unshipped or omitted their diagnostics: - comparison feature grid: xphp "Generic type aliases" ❌ →⚠️ (shipped, with the body-shape and file-local-generic caveats) - error catalog: add the six alias diagnostic codes (cycle, arity, class_collision, duplicate, unsupported_body, compound_in_non_slot) - docs/index and README: move type aliases from "under exploration" / "remaining" to shipped - syntax index: correct the one-line summary (non-generic cross-file, generic file-local; defaults + bounds) - type-bounds: note a bound may name an alias Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-pass) A type alias is a file-local declaration by design, like PHP's own `use` alias — there is no whole-program alias table. Remove WI-03's cross-file machinery: the Compiler's collectGlobalAliases, the parser's aliasTableOf, and the externalAliases parameter threaded through parse / parseWithMap / resolveAndAttach. Expansion now consults only the file's own alias table. This makes generic and non-generic aliases behave consistently (both file-local), and dissolves the two cross-file gaps entirely: a generic alias used in another file surfaces as an undefined template, a non-generic one is simply left unexpanded (flagged by the later PHP/PHPStan pass), and same-file duplicate/collision detection is unchanged. Share a vocabulary by declaring the alias in each file that uses it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type aliases are now file-local for all cases (WI-07). Rewrite the docs to present file-locality as an intentional design choice — an alias is a local naming convenience like a `use` alias, not a whole-program symbol — rather than a "safe subset first" limitation: caveats, syntax tour + index, roadmap (timeline + shipped), the comparison grid caveat, CHANGELOG, and ADR-0023's delivered-scope note. Drop the cross-file duplicate/collision "not detected" notes (moot — per-file scoping has nothing to detect across files). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ycle Address review feedback on the type-alias feature: - The `xphp.alias_unsupported_body` message listed unions and nullables as unsupported, but both are supported — it now names only intersection, DNF, and closure-signature bodies. - A self-referential alias whose cycle runs through a generic argument of a non-alias class (`type A<T> = Bag<A<T>>`) bypassed the cycle guard and recursed without bound; argument expansion now carries the same visited chain as the body, so it is a clean `xphp.alias_cycle`. - Refresh two stale docblocks that said duplicate/cycle/arity diagnostics "land in a later change" — all are implemented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7bdd772 to
1c4694f
Compare
Adds compile-time type aliases to xphp — a reusable name for a type, generic or not. An alias is a compile-time substitution: it is expanded into its body before specialization and has no runtime existence, so the emitted PHP never mentions the alias name and there is no runtime cost.
A use is replaced by its expanded body and then monomorphized exactly as if the body had been written by hand —
Pair<int, User>records and specializes the sameDict<int, Bag<User>>an explicit type would, with no separate code path.