Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/beyond-php/generics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,19 @@ In a generic class, a method can refer to the class's `T` and declare a separate
If a method already declares native type parameters, its written signature takes precedence
over the docblock.

A PHPDoc method template is erased, not monomorphized, when the method is dispatched
dynamically: an interface method, a method that implements or overrides another one
(including built-in interfaces such as `ArrayAccess`, `Countable` or `IteratorAggregate`),
or any method of a class with an undeclared ancestor. An instantiated method never takes a
vtable slot (see above), so specializing it would break interface conformance and override
dispatch; the method keeps its native signature and runs as php-src runs it. A method template
that no parameter or return position adopts is dropped the same way.

`instanceof` on a generic class or interface template (`$x instanceof Coll`) holds for every
instantiation and subclass, as in php-src, where the template is an ordinary class. The marker
a class template leaves behind is hidden from `interface_exists()`,
`get_declared_interfaces()` and `class_implements()`.

A constructor can also declare `@template`. Its arguments specialize the constructed class,
so promoted properties use the same concrete types as the constructor parameters:

Expand Down
15 changes: 13 additions & 2 deletions src/codegen/lower_inst/builtins/class_relations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ fn relation_names(

/// Computes implemented interface names for a class or parent interfaces for an interface.
fn class_implements(ctx: &FunctionContext<'_>, target: &ClassLikeTarget) -> Vec<String> {
match target {
let mut names = match target {
ClassLikeTarget::Class(class_name) => lookup_class(ctx, class_name)
.map(|info| info.interfaces.clone())
.unwrap_or_default(),
Expand All @@ -132,7 +132,18 @@ fn class_implements(ctx: &FunctionContext<'_>, target: &ClassLikeTarget) -> Vec<
names
}
ClassLikeTarget::Trait(_) | ClassLikeTarget::Unknown => Vec::new(),
}
};
names.retain(|name| !reflection_hidden_interface(ctx, name));
names
}

/// Whether `name` is a generic class template's marker interface, which answers `instanceof`
/// but is invisible to reflection (see `InterfaceInfo::reflection_hidden`).
pub(in crate::codegen::lower_inst) fn reflection_hidden_interface(
ctx: &FunctionContext<'_>,
name: &str,
) -> bool {
lookup_interface(ctx, name).is_some_and(|info| info.reflection_hidden)
}

/// Computes parent class names from the immediate parent through ancestors.
Expand Down
9 changes: 8 additions & 1 deletion src/codegen/lower_inst/builtins/member_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ fn class_like_exists_candidates(ctx: &FunctionContext<'_>, name: &str) -> Vec<St
.cloned(),
);
}
"interface_exists" => candidates.extend(ctx.module.interface_infos.keys().cloned()),
// A class template's marker interface is not an interface php-src would report.
"interface_exists" => candidates.extend(
ctx.module
.interface_infos
.iter()
.filter(|(_, info)| !info.reflection_hidden)
.map(|(name, _)| name.clone()),
),
"trait_exists" => candidates.extend(ctx.module.trait_table.names.iter().cloned()),
"enum_exists" => candidates.extend(ctx.module.enum_infos.keys().cloned()),
_ => {}
Expand Down
14 changes: 14 additions & 0 deletions src/codegen/lower_inst/builtins/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,16 @@ fn static_relation_holds(
let object_class = object_class.trim_start_matches('\\');
let target_class = target_class.trim_start_matches('\\');
let target_key = php_symbol_key(target_class);
if exclude_self && super::class_relations::reflection_hidden_interface(ctx, target_class) {
// `Box` names a generic class template, and php-src's `Box` is the one class every
// instantiation stands for: `is_subclass_of` holds for a class EXTENDING an
// instantiation, never for the instantiation itself.
return Ok(lookup_class(ctx, object_class)
.and_then(|info| info.parent.clone())
.is_some_and(|parent| {
class_interfaces_contain(ctx, parent.trim_start_matches('\\'), &target_key)
}));
}
if !exclude_self && php_symbol_key(object_class) == target_key {
return Ok(true);
}
Expand Down Expand Up @@ -1490,6 +1500,10 @@ fn declared_names(ctx: &FunctionContext<'_>, name: &str) -> Result<Vec<String>>
_ => unreachable!(),
};
}
if name == "get_declared_interfaces" {
// A class template's marker interface is not an interface php-src would declare.
names.retain(|interface| !super::class_relations::reflection_hidden_interface(ctx, interface));
}
Ok(names)
}

Expand Down
12 changes: 7 additions & 5 deletions src/docblock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ fn parse_template(rest: &str, variance: Variance) -> Option<TypeParam> {
bound,
default,
variance,
from_docblock: true,
})
}

Expand Down Expand Up @@ -904,10 +905,10 @@ mod tests {
generics.interface_args,
vec![vec![TypeExpr::Named(Name::unqualified("T"))]]
);
assert_eq!(
methods[0].return_type,
Some(TypeExpr::Named(Name::unqualified("T")))
);
// A bodiless method keeps its native signature: its PHPDoc only describes what an
// implementation returns, and adopting it would make an untyped implementation
// incompatible with a type php-src never declared.
assert_eq!(methods[0].return_type, None);
}

/// Written syntax wins over an annotation, so a file can migrate one class at a time — and
Expand Down Expand Up @@ -1017,7 +1018,8 @@ mod tests {
assert_eq!(method.type_params[1].default, Some(TypeExpr::Str));
assert_eq!(method.params[0].1, Some(TypeExpr::Named(Name::unqualified("T"))));
assert_eq!(method.variadic_type, Some(TypeExpr::Named(Name::unqualified("U"))));
assert_eq!(method.return_type, Some(TypeExpr::Int));
// `@return int` names no type parameter, so it does not narrow the native signature.
assert_eq!(method.return_type, None);
}

/// Native method declarations retain their written types even when PHPDoc disagrees.
Expand Down
157 changes: 144 additions & 13 deletions src/docblock/members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
//! Key details:
//! - Native method templates take precedence over PHPDoc.
//! - Ordinary member annotations only apply when they mention a class type parameter.
//! - An explicit method template applies its parameter and return annotations like a function.
//! - A method template honours annotations naming its own or the class's type parameters.
//! - A bodiless (interface/abstract) method keeps its native signature: its PHPDoc types are
//! claims about dynamic dispatch that php-src never enforces.
//! - Promoted properties retain the annotated constructor parameter's storage type.

use std::collections::HashMap;

use crate::parser::ast::{ClassMethod, ClassProperty, TypeExpr};
use crate::parser::ast::{ClassMethod, ClassProperty, TypeExpr, TypeParam};

use super::DocBlock;

Expand All @@ -29,7 +31,7 @@ pub(super) fn apply(
.get(&(property.span.line, property.span.col))
.and_then(|block| block.var_type.as_ref())
{
apply_type(&mut property.type_expr, annotated, class_params, false);
apply_type(&mut property.type_expr, annotated, class_params, &[]);
}
}
let mut promoted = Vec::new();
Expand All @@ -40,16 +42,33 @@ pub(super) fn apply(
if !method.type_params.is_empty() {
continue;
}
let is_template = block.declares_generics();
if is_template {
method.type_params = block.type_params.clone();
// A method with no body (an interface or abstract method) is only ever reached through
// dynamic dispatch, and an instantiated generic method never takes a vtable slot. Its
// PHPDoc is therefore not adopted at all: the method keeps its native signature, so an
// implementation that omits a type PHPDoc only claimed is still compatible, exactly as
// php-src sees it.
if !method.has_body {
continue;
}
// The names an annotation must mention to be honoured: the class's parameters, plus the
// method's own when it is a template. `@param string $key` beside a `@template` says
// nothing generic, and adopting it would narrow a parameter the native signature (and
// every interface the method implements) leaves untyped.
let mut in_scope: Vec<String> = class_params.to_vec();
let own: &[TypeParam] = if block.declares_generics() {
method.type_params = block.type_params.clone();
in_scope.extend(block.type_params.iter().map(|param| param.name.clone()));
&block.type_params
} else {
&[]
};
let in_scope = in_scope.as_slice();
let is_constructor = method.name.eq_ignore_ascii_case("__construct");
for (name, declared, _, _) in method.params.iter_mut() {
let Some(annotated) = block.params.get(name) else {
continue;
};
if apply_type(declared, annotated, class_params, is_template) && is_constructor {
if apply_type(declared, annotated, in_scope, own) && is_constructor {
promoted.push((name.clone(), annotated.clone()));
}
}
Expand All @@ -58,10 +77,62 @@ pub(super) fn apply(
.as_ref()
.and_then(|name| block.params.get(name))
{
apply_type(&mut method.variadic_type, annotated, class_params, is_template);
apply_type(&mut method.variadic_type, annotated, in_scope, own);
}
let native_return = method.return_type.clone();
if let Some(annotated) = &block.return_type {
apply_type(&mut method.return_type, annotated, class_params, is_template);
apply_type(&mut method.return_type, annotated, in_scope, own);
}
// A template the PHPDoc binds through a parameter, whose annotation then lost to a native
// hint (`@param T $v` on `mixed $v`), is no longer bound by anything at a call: calls
// infer method templates from their arguments only. Keeping it, and the return type it
// was adopted into, would make every ordinary call unresolvable, so both go and the
// method keeps its native signature there, which is all php-src ever saw. A template no
// `@param` mentions at all is left alone: that is the author's own unbindable template,
// and the call reports it.
let lost: Vec<String> = own
.iter()
.filter(|param| param.default.is_none())
.map(|param| param.name.clone())
.filter(|name| {
let name = std::slice::from_ref(name);
block
.params
.values()
.any(|annotated| annotated.mentions_type_param(name))
&& !method
.params
.iter()
.map(|(_, declared, _, _)| declared)
.chain([&method.variadic_type])
.flatten()
.any(|declared| declared.mentions_type_param(name))
})
.collect();
if !lost.is_empty() {
if method
.return_type
.as_ref()
.is_some_and(|declared| declared.mentions_type_param(&lost))
{
method.return_type = native_return;
}
method.type_params.retain(|param| !lost.contains(&param.name));
Comment on lines +118 to +120

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

}
// A template no position adopted (every annotation lost to a native hint) binds nothing
// at a call and would only make the call unresolvable; php-src never saw it either.
if !own.is_empty() {
let names: Vec<String> = own.iter().map(|param| param.name.clone()).collect();
let adopted = method
.params
.iter()
.map(|(_, declared, _, _)| declared)
.chain([&method.variadic_type, &method.return_type])
.flatten()
.any(|declared| declared.mentions_type_param(&names));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
if !adopted {
method.type_params.clear();
}
}
}
// The parser creates a promoted parameter and its property separately. Both must agree.
Expand All @@ -72,16 +143,76 @@ pub(super) fn apply(
}
}

/// Applies template annotations, retaining the class-parameter gate for ordinary members.
/// Applies an annotation only when it mentions a type parameter in scope (class or method) and
/// refines, rather than contradicts, the native hint.
///
/// PHPDoc may sharpen a hint php-src treats loosely (`array`, `iterable`, `callable`, `object`,
/// none at all, or the bare name of the class it parameterizes, or the bound of a method
/// template). A native `mixed` is kept. A native class or scalar hint is a contract php-src
/// enforces and every override is checked against, so it wins:
/// `getIterator(): Traversable` documented `@return ArrayIterator<TKey, TValue>` stays
/// `Traversable`.
fn apply_type(
declared: &mut Option<TypeExpr>,
annotated: &TypeExpr,
class_params: &[String],
method_template: bool,
in_scope: &[String],
method_params: &[TypeParam],
) -> bool {
if !method_template && !annotated.mentions_type_param(class_params) {
if !annotated.mentions_type_param(in_scope)
|| !refines_native(declared.as_ref(), annotated, method_params)
{
return false;
}
*declared = Some(annotated.clone());
true
}

/// Returns whether `annotated` may replace the native hint `native`.
///
/// `method_params` are the method's own PHPDoc templates: `@param T $m` with
/// `@template T of Model` refines a native `Model $m`, because every `T` the call can bind is a
/// `Model` (the bound is checked at the call), so the contract php-src enforces still holds.
fn refines_native(
native: Option<&TypeExpr>,
annotated: &TypeExpr,
method_params: &[TypeParam],
) -> bool {
let Some(native) = native else {
return true;
};
let same_class = |a: &crate::names::Name, b: &crate::names::Name| {
a.last_segment()
.zip(b.last_segment())
.is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))
};
match native {
TypeExpr::Array(_) | TypeExpr::AssocArray { .. } | TypeExpr::Iterable => true,
TypeExpr::Nullable(inner) => {
let annotated = match annotated {
TypeExpr::Nullable(inner) => inner.as_ref(),
other => other,
};
refines_native(Some(inner), annotated, method_params)
}
TypeExpr::Named(name) => {
let written = name.as_str().trim_start_matches('\\');
// `mixed` is not weak: it is already the widest type, so the PHPDoc cannot sharpen it
// without a later erasure (a dispatched method) having to guess whether to give the
// `mixed` back or leave the position untyped, and only `mixed` keeps an
// implementation of `JsonSerializable::jsonSerialize(): mixed` compatible.
let weak = ["callable", "object", "closure", "array", "iterable"]
.iter()
.any(|weak| written.eq_ignore_ascii_case(weak));
weak || match annotated {
TypeExpr::GenericClass { name: generic, .. } => same_class(generic, name),
// A method template bounded by the native class (`T of Model` on `Model`).
TypeExpr::Named(param) => method_params.iter().any(|tp| {
tp.name == param.as_str()
&& matches!(&tp.bound, Some(TypeExpr::Named(bound)) if same_class(bound, name))
}),
_ => false,
}
}
_ => false,
}
}
12 changes: 10 additions & 2 deletions src/generics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

pub mod classes;
mod constructors;
mod dispatch;
pub(crate) use constructors::parameter_name;
pub mod methods;
mod report;
Expand Down Expand Up @@ -93,11 +94,14 @@ where
// in the pipeline is what keeps the two in step — an instantiated FUNCTION body can name
// `Box<T>`, which only becomes the concrete `Box<int>` once the checker has bound `T`.
let program = constructors::lift(program);
// A generic method reached through dynamic dispatch cannot be monomorphized (instantiations
// are called statically), so it is erased before any template is collected or copied.
let program = dispatch::erase_dispatched(program);
let mut class_templates = classes::collect(&program);
// A variance marker is a promise about the DECLARATION, so it is checked once, here, before
// anything is spliced. Checking it per instantiation would name a class the programmer never
// wrote, and checking it after splicing would be too late to name the member at fault.
variance::verify(&class_templates)?;
let variance_warnings = variance::verify(&mut class_templates)?;
let first = constructors::instantiate(
program,
&mut class_templates,
Expand All @@ -107,7 +111,8 @@ where
let obligations = first.obligations;
// Warnings the INSTANTIATING pass produced — a bare template name read as an instantiation —
// travel with the checker's, so a compile reports both in one place.
let mut instantiation_warnings = first.warnings;
let mut instantiation_warnings = variance_warnings;
instantiation_warnings.extend(first.warnings);
let mut context = GenericContext {
class_type_argument_bounds: obligations,
// Carried for the checker's own half of the job: `new Box(5)` writes no type arguments,
Expand Down Expand Up @@ -803,6 +808,7 @@ mod tests {
bound: None,
default: None,
variance: Variance::Invariant,
from_docblock: false,
}
}

Expand Down Expand Up @@ -923,6 +929,7 @@ mod tests {
bound: None,
default: Some(TypeExpr::Str),
variance: Variance::Invariant,
from_docblock: false,
};
let bindings = infer_bindings(&[declared], &[param(TypeExpr::Int)], &[PhpType::Int])
.expect("the default determines K");
Expand All @@ -937,6 +944,7 @@ mod tests {
bound: None,
default: Some(TypeExpr::Str),
variance: Variance::Invariant,
from_docblock: false,
};
let bindings = infer_bindings(&[declared], &[param(t("T"))], &[PhpType::Int])
.expect("T is constrained by the argument");
Expand Down
Loading
Loading