Skip to content

feat(type-aliases): file-local generic type aliases via compile-time substitution - #32

Open
math3usmartins wants to merge 17 commits into
0.4.xfrom
feature/type-aliases
Open

feat(type-aliases): file-local generic type aliases via compile-time substitution#32
math3usmartins wants to merge 17 commits into
0.4.xfrom
feature/type-aliases

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

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.

type UserId     = Ident;               // non-generic
type Pair<A, B> = Dict<A, Bag<B>>;     // generic
type Num        = int|string;          // union body
type MaybeUser  = ?User;               // nullable body

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 same Dict<int, Bag<User>> an explicit type would, with no separate code path.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds file-local compile-time type aliases to xphp (type Name[<A,B>] = Body;), expanding alias references into their bodies before monomorphization so the emitted PHP never mentions the alias name. Supported body forms are single-head (generic or not), union (int|string), and nullable (?Box); intersection, DNF, and closure-signature bodies are rejected.

  • Core expansion engine (XphpSourceParser): scan strips type … ; declarations to whitespace, builds a per-file alias table, and a new expandAliasToUnion / expandAlias pair replaces every alias reference in the AST with its resolved body before normal monomorphization proceeds. Cycle detection threads the $visited chain through both body expansion and argument expansion (line 4184), catching type A<T> = Bag<A<T>>-style argument-path cycles in addition to direct body cycles.
  • Deferred bound validation (AliasBoundObligation*/AliasBoundValidator): because the TypeHierarchy does not exist at parse time, alias-parameter bounds are captured as obligations during parsing and verified in a single post-hierarchy pass, reusing the same Registry::checkAliasBounds seam as generic classes.
  • Error coverage: six stable diagnostic codes (xphp.alias_cycle, xphp.alias_arity, xphp.alias_duplicate, xphp.alias_class_collision, xphp.alias_unsupported_body, xphp.alias_compound_in_non_slot) are thrown in compile mode and collected in check mode, with dedicated integration tests for each rejection path including the argument-path cycle variant.

Confidence Score: 5/5

Safe to merge — both previously flagged P1 issues are confirmed fixed and only P2-level observations remain.

Both prior P1 findings have been resolved: argument expansion now correctly threads $visited through expandAlias (preventing silent infinite expansion on arg-path alias cycles), and the unsupported-body error message no longer incorrectly lists union/nullable bodies. The two remaining observations (PHP keyword types array/callable unsupported in alias bodies, and the asymmetric empty-$visited passed from buildBoundExprNode) are P2-level and both have architectural justifications. Test coverage in TypeAliasIntegrationTest is thorough, including an explicit test for the previously-flagged argument-cycle case.

Files Needing Attention: No files require special attention. XphpSourceParser.php is the largest and most complex changed file but the logic is well-tested.

Important Files Changed

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"]
Loading

Reviews (2): Last reviewed commit: "fix(monomorphize): correct alias-body me..." | Re-trigger Greptile

Comment thread src/Transpiler/Monomorphize/XphpSourceParser.php
math3usmartins and others added 17 commits August 5, 2026 02:42
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant