Skip to content

fix(generics): erase PHPDoc templates on dispatched methods, instanceof on templates - #1920

Open
JGamboa wants to merge 4 commits into
illegalstudio:mainfrom
JGamboa:fix/generics-docblock-followups
Open

JGamboa wants to merge 4 commits into
illegalstudio:mainfrom
JGamboa:fix/generics-docblock-followups

Conversation

@JGamboa

@JGamboa JGamboa commented Oct 19, 2026

Copy link
Copy Markdown

Summary

Since the generics monomorphization (#1221), several plain-PHP @template patterns that
php-src runs (Laravel's and PHPStan-annotated code is full of them) are rejected or
miscompiled on main. This PR re-validates the follow-ups against main's current generics
(monomorphization, #1819, #1822) and fixes them:

Dynamically dispatched methods:

  • an interface method's PHPDoc @template is erased: the interface keeps its native
    signature, so TInstance is no longer an Unknown type and implementations still
    conform;
  • a templated method that implements a plain interface method, or overrides a parent
    method, keeps its vtable slot, so conformance and override dispatch work (main dispatched
    to the parent: Base 5 instead of Child 5);
  • a @template-covariant marker that the declaration itself breaks (a writable property,
    a parameter) is demoted to invariant with a warning instead of rejecting a file php-src
    runs;
  • PHPStan-style type arguments on undeclared (built-in) interfaces
    (@implements IteratorAggregate<string, int>, ArrayAccess<K, V>) are erased;
  • a non-generic class that implements a PHPDoc-generic interface by its bare name still
    implements it (main: Unknown interface: Arrayable).

Templates and instanceof:

  • each class or interface template leaves an empty marker interface that every
    instantiation inherits, so $x instanceof Coll holds for every instantiation and
    subclass, as in php-src (main: false for all of them); an instanceof guard on a
    template does not narrow to the member-less marker;
  • a method template bounded by the native class (T of Model on Model) refines it; a
    template that no position adopts is dropped (the method compiles untyped, as php-src
    runs it); a native mixed is no longer replaced by PHPDoc;
  • erased array<mixed, mixed> / mixed[] become plain array, so a dispatched method's
    physical signature stays compatible;
  • methods implementing built-in interfaces (ArrayAccess, Countable, IteratorAggregate,
    JsonSerializable, ...) count as dispatched; any other undeclared ancestor makes every
    method dispatched.

Reflection: the marker a class template leaves behind is hidden from
interface_exists, get_declared_interfaces and class_implements, and
is_subclass_of($x, 'Box') holds only for a class extending an instantiation, as in
php-src. Interface-template markers stay visible. An instanceof guard on a template keeps
an object local's class and reads anything else as mixed.

The erasure rule is documented in docs/beyond-php/generics.md. There is no environment
switch to turn PHPDoc generics off.

The changes land as one commit because they share the docblock and generics passes; happy to
split it (dispatched-method erasure / template markers and instanceof / reflection) if you
prefer.

Closes #1899 (follow-up of #1221).

Changed upstream test

test_one_line_block_reads_its_opening_tag (from c769f6c) expected
does not determine type parameter <T> for the function, static method, trait method and
enum method forms of a one-line /** @template T of int @param T $v @return T */. With the
"a method template no position adopts is dropped" rule, the three METHOD forms now compile
untyped and print seven, which is what php-src prints. The function form and the
multi-line bound violation keep the upstream assertions.

Tests

18 new tests in tests/codegen/generics.rs and tests/codegen/generics_docblock_methods.rs.
Expected outputs come from php 8.5, except one native-syntax test (class Box<T>) that php
cannot parse.

  • Fail on main: 17. 15 are compile errors (Unknown type: TInstance, Class ArrayRepo must implement interface method Repo::get, Unknown interface: Arrayable, a covariance error,
    'IteratorAggregate' is written with type arguments but declares no type parameters,
    Cannot implement interface method IntColl::first without declaring a compatible return type, ...). Two compile and print the wrong result: override dispatch (Base 5) and
    instanceof on a class template (all false).
  • Control, passes on main: test_docblock_bounded_template_refines_the_native_bound
    (marked in its doc comment).

Based on main df57a98 (verified against a build of that SHA).

Local runs: cargo build; error_tests, parser_tests, lexer_tests;
codegen_tests -- generics docblock (290); lib tests for docblock, generics, source::
and narrowing; codegen instanceof class_implements interface_exists is_subclass_of declared_interfaces.

Refs #1886

AI assistance

This change was written with substantial help from Claude Code (Anthropic). I reviewed
it, checked the expected outputs against php 8.5, and remain responsible for it.

🤖 Generated with Claude Code

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:fix Corrects broken or incompatible behavior. labels Oct 19, 2026
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes affect how compiled PHP programs use PHPDoc generics.

Fix the newly broken explicit method calls before merging.

Fix All in Claude CodeFindings

  1. P1 Explicit method calls stop compiling ▶
Fix with agent prompt
### Issue 1
src/docblock/members.rs:118-120
Removing a template here breaks calls that compiled before this update. With separate `@template T`, `@param T $v`, and `@return T` tags on `public function id(mixed $v) { return $v; }`, `$repo->id<int>(7)` previously bound `T` explicitly.

This change removes `T` and restores the native return, so only ordinary `id` remains. The explicit call still looks for `id<int>` and fails with `Undefined method`. Preserve explicit calls while allowing ordinary calls to use the erased signature.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR erases templates on dispatched methods and adds shared markers for generic instanceof checks.

  • PHPDoc templates keep the method contracts that dynamic calls need.
  • Generic templates match every instantiation in instanceof checks.
  • Generic interface annotations work with built-in and ordinary PHP interfaces.

Reviews (4) · Last reviewed commit: "fix(docblock): drop a method template wh..." · Reviewed by Greptile

Comment thread src/generics/dispatch.rs
Comment thread src/generics/dispatch.rs
Comment thread src/generics/classes.rs
…of on templates

Since the monomorphization (illegalstudio#1221), plain-PHP @template patterns that php-src
runs were rejected or miscompiled.

Dynamically dispatched methods:
- An interface method's PHPDoc @template is erased; the interface keeps its
  native signature and implementations still conform.
- A templated method that implements or overrides another keeps its vtable
  slot, so override dispatch reaches the child.
- A @template-covariant marker broken by the declaration is demoted to
  invariant with a warning.
- Docblock type arguments on undeclared (builtin) interfaces are erased.
- A plain class implementing a PHPDoc-generic interface by its bare name
  implements its default instantiation.
- Methods implementing builtin interfaces count as dispatched; any other
  undeclared ancestor makes every method dispatched.

Templates:
- Each class/interface template leaves an empty marker interface that every
  instantiation inherits, so `instanceof Coll` holds for every instantiation
  and subclass; an instanceof guard on a template does not narrow to the
  member-less marker, keeps an object local's class and reads anything else
  as mixed.
- A method template bounded by the native class refines it; a template no
  position adopts is dropped; a native `mixed` is not replaced by PHPDoc.
- Erased `array<mixed, mixed>` / `mixed[]` become plain `array`.

Reflection: the marker a class template leaves is hidden from
interface_exists, get_declared_interfaces and class_implements, and
is_subclass_of(x, 'Box') holds only for a class extending an instantiation.

test_one_line_block_reads_its_opening_tag: the three one-line METHOD forms now
compile untyped and print php-src's output, since their template is adopted by
no position; the function and multi-line assertions are unchanged.
@JGamboa
JGamboa force-pushed the fix/generics-docblock-followups branch from 7ba2536 to a71b166 Compare October 21, 2026 06:04
Comment thread src/docblock/members.rs
Comment thread src/generics/classes.rs
Enums share the class namespace but were missing from the declared-name
set, so a type argument on a declared enum (`Suit<int>`) was erased as if
the name came from outside the program instead of being reported as on a
class that declares no type parameters.
…e hint

When a method template's only @PARAM annotation loses to a native hint
(`@param T $v` on `mixed $v`), nothing at a call can bind the template,
since method templates are inferred from arguments only. Keeping it and the
`@return T` adopted from it made ordinary calls fail with 'does not
determine type parameter <T>'. The template is now dropped and the return
keeps its native type, as php-src sees the method.
Comment thread src/docblock/members.rs
Comment on lines +118 to +120
method.return_type = native_return;
}
method.type_params.retain(|param| !lost.contains(&param.name));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Explicit method calls stop compiling

Removing a template here breaks calls that compiled before this update. With separate @template T, @param T $v, and @return T tags on public function id(mixed $v) { return $v; }, $repo->id<int>(7) previously bound T explicitly.

This change removes T and restores the native return, so only ordinary id remains. The explicit call still looks for id<int> and fails with Undefined method. Preserve explicit calls while allowing ordinary calls to use the erased signature.

Knowledge Base Used: Generic language support

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/docblock/members.rs
Line: 118-120

Comment:
**Explicit method calls stop compiling**

Removing a template here breaks calls that compiled before this update. With separate `@template T`, `@param T $v`, and `@return T` tags on `public function id(mixed $v) { return $v; }`, `$repo->id<int>(7)` previously bound `T` explicitly.

This change removes `T` and restores the native return, so only ordinary `id` remains. The explicit call still looks for `id<int>` and fails with `Undefined method`. Preserve explicit calls while allowing ordinary calls to use the erased signature.

**Knowledge Base Used:** [Generic language support](https://app.greptile.com/illegal-studio/-/custom-context/knowledge-base/illegalstudio/elephc/-/docs/generic-language-support.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plain-PHP code with @template docblocks is rejected or miscompiled since the generics monomorphization (#1221)

1 participant