diff --git a/docs/beyond-php/generics.md b/docs/beyond-php/generics.md index bd05695bb9..d52b4f409f 100644 --- a/docs/beyond-php/generics.md +++ b/docs/beyond-php/generics.md @@ -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: diff --git a/src/codegen/lower_inst/builtins/class_relations.rs b/src/codegen/lower_inst/builtins/class_relations.rs index 4b4a1c0f5f..fdba02f8e7 100644 --- a/src/codegen/lower_inst/builtins/class_relations.rs +++ b/src/codegen/lower_inst/builtins/class_relations.rs @@ -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 { - match target { + let mut names = match target { ClassLikeTarget::Class(class_name) => lookup_class(ctx, class_name) .map(|info| info.interfaces.clone()) .unwrap_or_default(), @@ -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. diff --git a/src/codegen/lower_inst/builtins/member_queries.rs b/src/codegen/lower_inst/builtins/member_queries.rs index e81dd28071..3d0579a35e 100644 --- a/src/codegen/lower_inst/builtins/member_queries.rs +++ b/src/codegen/lower_inst/builtins/member_queries.rs @@ -79,7 +79,14 @@ fn class_like_exists_candidates(ctx: &FunctionContext<'_>, name: &str) -> Vec 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()), _ => {} diff --git a/src/codegen/lower_inst/builtins/types.rs b/src/codegen/lower_inst/builtins/types.rs index 948739c58f..e51d153bb6 100644 --- a/src/codegen/lower_inst/builtins/types.rs +++ b/src/codegen/lower_inst/builtins/types.rs @@ -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); } @@ -1490,6 +1500,10 @@ fn declared_names(ctx: &FunctionContext<'_>, name: &str) -> Result> _ => 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) } diff --git a/src/docblock.rs b/src/docblock.rs index 099dd8f7c7..43eaf728d7 100644 --- a/src/docblock.rs +++ b/src/docblock.rs @@ -392,6 +392,7 @@ fn parse_template(rest: &str, variance: Variance) -> Option { bound, default, variance, + from_docblock: true, }) } @@ -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 @@ -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. diff --git a/src/docblock/members.rs b/src/docblock/members.rs index c0b95cc1de..d042bb41cc 100644 --- a/src/docblock/members.rs +++ b/src/docblock/members.rs @@ -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; @@ -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(); @@ -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 = 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())); } } @@ -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 = 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(¶m.name)); + } + // 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 = 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)); + if !adopted { + method.type_params.clear(); + } } } // The parser creates a promoted parameter and its property separately. Both must agree. @@ -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` stays +/// `Traversable`. fn apply_type( declared: &mut Option, 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, + } +} diff --git a/src/generics.rs b/src/generics.rs index 6953aaf8fe..313495e2ec 100644 --- a/src/generics.rs +++ b/src/generics.rs @@ -21,6 +21,7 @@ pub mod classes; mod constructors; +mod dispatch; pub(crate) use constructors::parameter_name; pub mod methods; mod report; @@ -93,11 +94,14 @@ where // in the pipeline is what keeps the two in step — an instantiated FUNCTION body can name // `Box`, which only becomes the concrete `Box` 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, @@ -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, @@ -803,6 +808,7 @@ mod tests { bound: None, default: None, variance: Variance::Invariant, + from_docblock: false, } } @@ -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"); @@ -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"); diff --git a/src/generics/classes.rs b/src/generics/classes.rs index 20ddae7f0a..d9f3a1b500 100644 --- a/src/generics/classes.rs +++ b/src/generics/classes.rs @@ -124,6 +124,9 @@ pub struct TemplateSignature { /// factory is how a real codebase offers more than one way to build something — inference /// that fires only at `new` would miss where most construction happens. pub static_methods: Vec, + /// Whether the template is a CLASS (rather than an interface or trait), whose marker + /// interface must stay invisible to reflection. + pub is_class: bool, } /// One static method of a generic class, as the checker needs it to infer at a call. @@ -158,6 +161,7 @@ impl Templates { constructor_params: constructor_param_types(&template.declaration), constructor_variadic: constructor_variadic_type(&template.declaration), static_methods: static_method_signatures(&template.declaration), + is_class: matches!(template.declaration.kind, StmtKind::ClassDecl { .. }), }) .collect() } @@ -178,6 +182,20 @@ impl Templates { self.by_name.values() } + /// Demotes one template parameter to invariant, the reading of a PHPDoc marker the + /// declaration does not keep. Returns whether a parameter was changed. + pub(super) fn demote_to_invariant(&mut self, key: &str, param: &str) -> bool { + let Some(template) = self.by_name.get_mut(key) else { + return false; + }; + let mut changed = false; + for declared in template.type_params.iter_mut().filter(|p| p.name == param) { + changed |= declared.variance != crate::parser::ast::Variance::Invariant; + declared.variance = crate::parser::ast::Variance::Invariant; + } + changed + } + /// One template by its normalized name, for a pass that follows an inheritance clause. pub(super) fn by_key(&self, key: &str) -> Option<&Template> { self.by_name.get(key) @@ -376,6 +394,12 @@ struct Instantiate<'a> { generic_functions: &'a std::collections::HashSet, /// Template names declared inside a conditional, for the diagnostic that names them. conditional: &'a std::collections::HashSet, + /// Every class-like name the program itself declares, by template key. + /// + /// A mention of a name OUTSIDE this set is a built-in (`IteratorAggregate`, `ArrayAccess`) + /// or an unknown class; its PHPStan-style type arguments describe nothing elephc stores, so + /// they are erased rather than rejected. + declared: &'a std::collections::HashSet, /// (enclosing function, `new` site) -> the class the checker inferred for it. new_names: &'a HashMap<(String, Span), String>, /// `RefCell` because `Pass::transform_type` takes `&self`: the walk rebuilds the tree and @@ -416,15 +440,44 @@ struct Instantiate<'a> { /// own `substitute_relative_class_types` runs far too late: this pass has already emitted /// a declaration by then. enclosing_classes: Vec, + /// Whether the walk is inside the inheritance clauses of the innermost class. + /// + /// An instantiation names its template's MARKER interface there (see + /// [`marker_interface`]), and that bare name must stay the marker rather than be read as + /// one more instantiation of the template. + in_heritage: bool, } impl Instantiate<'_> { + /// Whether `key` is the template the innermost class instantiates, written in its own + /// inheritance clause: the marker link [`instantiate_declaration`] adds, never a mention. + /// + /// An instantiated name is the template's declared spelling followed by `<`, a character no + /// written name can carry, so the prefix identifies the template exactly. A template can + /// itself be an instantiation (`Pair` once its constructor's `` is lifted), so its + /// instantiation `Pair` inherits both `Pair` and `Pair`. + fn is_own_marker(&self, key: &str) -> bool { + self.in_heritage + && self.enclosing_classes.last().is_some_and(|class| { + template_key(class) + .strip_prefix(key) + .is_some_and(|rest| rest.starts_with('<')) + }) + } + /// Rewrites one type, recursing so a generic class nested anywhere inside is reached. /// /// The walker hands over a whole annotation (`?array>`), not one node at a time, /// so the recursion belongs here rather than in the walker. fn rewrite(&self, ty: TypeExpr, span: Span) -> TypeExpr { match ty { + TypeExpr::GenericClass { name, .. } + if !self.templates.contains_key(&template_key(name.as_str())) + && !self.conditional.contains(&template_key(name.as_str())) + && !self.declared.contains(&template_key(name.as_str())) => + { + TypeExpr::Named(name) + } TypeExpr::GenericClass { name, args } => { let args: Vec = args .into_iter() @@ -446,6 +499,9 @@ impl Instantiate<'_> { // accepts a `Repo` only where the parameter is declared covariant (`out T`); // on an invariant template this parameter takes exactly one type, and saying which // is the better program. + TypeExpr::Named(name) if self.is_own_marker(&template_key(name.as_str())) => { + TypeExpr::Named(name) + } TypeExpr::Named(name) if self.templates.contains_key(&template_key(name.as_str())) => { let template = &self.templates[&template_key(name.as_str())]; let arguments: Vec = template @@ -726,6 +782,16 @@ impl Pass for Instantiate<'_> { self.site_scope.leave_body(); } + /// Marks the inheritance clauses of the innermost class. + fn enter_heritage(&mut self) { + self.in_heritage = true; + } + + /// Leaves the inheritance clauses of the innermost class. + fn leave_heritage(&mut self) { + self.in_heritage = false; + } + /// Restores the enclosing class and template depth. fn leave_class(&mut self) { // Only a template incremented it, and the walker pairs every enter with a leave, so a @@ -776,11 +842,17 @@ pub fn instantiate( let mut obligations = Vec::new(); let mut warnings: Vec = Vec::new(); let mut rounds = 0usize; + let declared: std::collections::HashSet = { + let mut names = Vec::new(); + collect_declared_names(&program, &mut names); + names.iter().map(|name| template_key(name)).collect() + }; loop { let mut pass = Instantiate { templates, generic_functions: &templates_store.generic_functions, conditional: &templates_store.conditional, + declared: &declared, new_names: &new_names, requests: RefCell::new(Vec::new()), obligations: RefCell::new(Vec::new()), @@ -790,6 +862,7 @@ pub fn instantiate( method_templates: Vec::new(), site_scope: Default::default(), enclosing_classes: Vec::new(), + in_heritage: false, }; let walked = walk_program(program, &mut pass); let mut errors = pass.errors.into_inner(); @@ -991,15 +1064,19 @@ fn splice( (program, added) } -/// Collects every class, interface and trait name the program declares, namespace blocks -/// included: the three share one namespace, so an instantiation must not reuse any of them. +/// Collects every class, interface, trait and enum name the program declares, namespace blocks +/// included: the four share one namespace, so an instantiation must not reuse any of them, and +/// a type argument on any of them is a mistake to report rather than a name to erase. fn collect_declared_names(stmts: &[Stmt], names: &mut Vec) { for stmt in stmts { match &stmt.kind { StmtKind::ClassDecl { name, .. } | StmtKind::InterfaceDecl { name, .. } - | StmtKind::TraitDecl { name, .. } => names.push(name.clone()), - StmtKind::NamespaceBlock { body, .. } => collect_declared_names(body, names), + | StmtKind::TraitDecl { name, .. } + | StmtKind::EnumDecl { name, .. } => names.push(name.clone()), + StmtKind::NamespaceBlock { body, .. } | StmtKind::Synthetic(body) => { + collect_declared_names(body, names) + } _ => {} } } @@ -1014,8 +1091,22 @@ fn collect_declared_names(stmts: &[Stmt], names: &mut Vec) { fn instantiate_declaration(template: &Stmt, target: &str, bindings: &Bindings) -> Stmt { let mut declaration = template.clone(); match &mut declaration.kind { - StmtKind::ClassDecl { name, generics, .. } - | StmtKind::InterfaceDecl { name, generics, .. } => { + StmtKind::ClassDecl { name, generics, implements, .. } => { + // Every instantiation implements the template's marker interface, so `instanceof + // Coll` holds for `Coll` and for anything extending it. + implements.push(Name::unqualified(name.as_str())); + *name = target.to_string(); + if let Some(declared) = generics.take() { + let GenericDecl { + type_params: _, + extends_args, + interface_args, + } = *declared; + *generics = GenericDecl::new(Vec::new(), extends_args, interface_args); + } + } + StmtKind::InterfaceDecl { name, generics, extends, .. } => { + extends.push(Name::unqualified(name.as_str())); *name = target.to_string(); if let Some(declared) = generics.take() { let GenericDecl { @@ -1041,31 +1132,56 @@ fn instantiate_declaration(template: &Stmt, target: &str, bindings: &Bindings) - .expect("substituting one declaration yields one declaration") } -/// Removes every generic class, interface and trait template from the program. +/// Removes every generic class, interface and trait template from the program, leaving an +/// empty MARKER interface under each class or interface template's name. /// /// A template is not a class: its annotations name types that have no representation, so the /// checker must never see it. Called once the fixpoint has produced every instantiation the /// program reaches. +/// +/// The marker is what `$x instanceof Coll` tests: php-src has one class `Coll`, monomorphization +/// has one per instantiation, and each of them implements (or, for an interface, extends) the +/// marker, so the test holds for every instantiation and every class built on one. It declares +/// no members, so it constrains nothing an instantiation declares. A trait is never an +/// `instanceof` target and leaves nothing behind. fn strip_templates(program: Program) -> Program { program .into_iter() .filter_map(|stmt| { - let is_template = match &stmt.kind { - StmtKind::ClassDecl { generics, .. } - | StmtKind::InterfaceDecl { generics, .. } - | StmtKind::TraitDecl { generics, .. } => generics + let is_template = |generics: &Option>| { + generics .as_ref() - .is_some_and(|generics| !generics.type_params.is_empty()), - _ => false, + .is_some_and(|generics| !generics.type_params.is_empty()) }; - if is_template { - return None; + match &stmt.kind { + StmtKind::ClassDecl { name, generics, .. } + | StmtKind::InterfaceDecl { name, generics, .. } + if is_template(generics) => + { + Some(marker_interface(name, &stmt)) + } + StmtKind::TraitDecl { generics, .. } if is_template(generics) => None, + _ => Some(strip_in_namespace(stmt)), } - Some(strip_in_namespace(stmt)) }) .collect() } +/// The empty interface left in a class or interface template's place (see [`strip_templates`]). +fn marker_interface(name: &str, template: &Stmt) -> Stmt { + Stmt { + kind: StmtKind::InterfaceDecl { + name: name.to_string(), + generics: None, + extends: Vec::new(), + properties: Vec::new(), + methods: Vec::new(), + constants: Vec::new(), + }, + ..template.clone() + } +} + /// Applies [`strip_templates`] inside a namespace block, and returns anything else unchanged. fn strip_in_namespace(stmt: Stmt) -> Stmt { let mut stmt = stmt; @@ -1098,13 +1214,93 @@ mod tests { crate::name_resolver::resolve(program).expect("name resolution failed") } - /// Returns the names of every class and interface the program declares. + /// Returns the names of every class and interface the program declares, leaving out the + /// marker interfaces templates leave behind (see [`markers`]). fn declared(program: &Program) -> Vec { let mut names = Vec::new(); collect_declared_names(program, &mut names); + let markers = markers(program); + names.retain(|name| !markers.contains(name)); names } + /// Returns the marker interfaces: the empty interfaces templates leave behind. None of + /// these tests declares an empty interface of its own. + fn markers(program: &Program) -> Vec { + /// Pushes the name of every member-less interface declared in `stmts`, descending into + /// namespace blocks. + fn walk(stmts: &[Stmt], out: &mut Vec) { + for stmt in stmts { + match &stmt.kind { + StmtKind::InterfaceDecl { name, extends, methods, properties, constants, .. } + if extends.is_empty() + && methods.is_empty() + && properties.is_empty() + && constants.is_empty() => + { + out.push(name.clone()) + } + StmtKind::NamespaceBlock { body, .. } => walk(body, out), + _ => {} + } + } + } + let mut out = Vec::new(); + walk(program, &mut out); + out + } + + /// A class or interface template leaves an empty marker interface under its own name, and + /// every instantiation inherits it, so `instanceof Box` holds for each of them. + #[test] + fn test_a_template_leaves_a_marker_every_instantiation_inherits() { + let program = resolved_program( + " { private T $v; } interface Shape { } \ + Box $a = 1; Box $b = 2; Shape $s = 3;", + ); + let templates = collect(&program); + let program = instantiate(program, &templates, &InferredConstructions::default()) + .expect("instantiation failed") + .program; + let mut found = markers(&program); + found.sort(); + assert_eq!(found, vec!["App\\Box".to_string(), "App\\Shape".to_string()]); + let mut inherits = Vec::new(); + let mut visit = |stmts: &[Stmt]| { + for stmt in stmts { + match &stmt.kind { + StmtKind::ClassDecl { name, implements, .. } => inherits.push(( + name.clone(), + implements.iter().map(|n| n.as_str().to_string()).collect::>(), + )), + StmtKind::InterfaceDecl { name, extends, methods, .. } if name.contains('<') => { + assert!(methods.is_empty()); + inherits.push(( + name.clone(), + extends.iter().map(|n| n.as_str().to_string()).collect(), + )) + } + _ => {} + } + } + }; + visit(&program); + for stmt in &program { + if let StmtKind::NamespaceBlock { body, .. } = &stmt.kind { + visit(body); + } + } + inherits.sort(); + assert_eq!( + inherits, + vec![ + ("App\\Box".to_string(), vec!["App\\Box".to_string()]), + ("App\\Box".to_string(), vec!["App\\Box".to_string()]), + ("App\\Shape".to_string(), vec!["App\\Shape".to_string()]), + ] + ); + } + /// Two type arguments produce two classes, and the template is gone. #[test] fn test_two_type_arguments_produce_two_classes() { @@ -1195,6 +1391,9 @@ mod tests { declared(&program).is_empty(), "nothing mentions either template, so neither is instantiated" ); + let mut found = markers(&program); + found.sort(); + assert_eq!(found, vec!["Box".to_string(), "Wrapper".to_string()]); } /// A trailing parameter with no argument falls back to its default. diff --git a/src/generics/dispatch.rs b/src/generics/dispatch.rs new file mode 100644 index 0000000000..cc3376b040 --- /dev/null +++ b/src/generics/dispatch.rs @@ -0,0 +1,349 @@ +//! Purpose: +//! Erases generic methods that take part in dynamic dispatch, before any template is collected. +//! +//! Called from: +//! - `crate::generics::monomorphize`, once, after constructor lifting and before class templates +//! are collected. +//! +//! Key details: +//! - An instantiated generic method is called statically and never takes a vtable slot, so a +//! template on an interface or abstract method, on a method that implements or overrides an +//! inherited one, or on a method a subclass overrides, cannot be monomorphized soundly. +//! - Erasure keeps the method and drops its type parameters plus every declared type naming one, +//! which is how php-src reads the same PHPDoc. Trait methods are judged by their using classes. +//! - On a PHPDoc template (`TypeParam::from_docblock`), a dispatched method also drops the types +//! naming the CLASS parameters, so an instantiation never narrows an inherited signature that +//! php-src sees untyped. Constructors and native templates are untouched. + +use std::collections::{BTreeSet, HashMap}; + +use crate::parser::ast::{ClassMethod, Program, Stmt, StmtKind, TypeExpr}; + +/// One class-like declaration as the erasure pass sees it: what it inherits and what it declares. +#[derive(Default)] +struct Owner { + is_interface: bool, + parent: Option, + interfaces: Vec, + traits: Vec, + methods: BTreeSet, +} + +/// Erases every generic method that takes part in dynamic dispatch. +/// +/// An instantiated generic method is dispatched STATICALLY (see the module docs), so a template +/// is only sound where the call site's static receiver names the exact method that runs. That +/// fails as soon as the method is declared on an interface, is abstract, implements or +/// overrides an inherited method, or is overridden by a subclass: the call renamed to +/// `Base::pick` would run `Base`'s body for a `Child`, and a class whose template was +/// stripped would no longer implement its interface. Such a method keeps its declaration and +/// loses its type parameters; every type naming one of them is dropped, which is php-src's view +/// of the same PHPDoc. +pub fn erase_dispatched(program: Program) -> Program { + let mut owners: HashMap = HashMap::new(); + index_owners(&program, &mut owners); + let mut children: HashMap> = HashMap::new(); + let mut users: HashMap> = HashMap::new(); + for (key, owner) in &owners { + for inherited in owner.parent.iter().chain(owner.interfaces.iter()) { + children.entry(inherited.clone()).or_default().push(key.clone()); + } + for used in &owner.traits { + users.entry(used.clone()).or_default().push(key.clone()); + } + } + let mut dispatched: BTreeSet<(String, String)> = BTreeSet::new(); + for (key, owner) in &owners { + let mut related = BTreeSet::new(); + let mut seen = BTreeSet::new(); + // A trait's method is dispatched wherever a class using it would dispatch it. + let mut hosts = vec![key.clone()]; + hosts.extend(users_of(key, &users)); + for host in &hosts { + ancestors_methods(host, &owners, &mut related, &mut seen, false); + descendants_methods(host, &owners, &children, &mut related, &mut BTreeSet::new()); + } + let everything = related.contains(ANY_METHOD); + for method in &owner.methods { + if owner.is_interface || everything || related.contains(method) { + dispatched.insert((key.clone(), method.clone())); + } + } + } + let mut program = program; + erase_in(&mut program, &dispatched); + program +} + +/// Records every class, interface, trait and enum with its inheritance edges and method names. +fn index_owners(stmts: &[Stmt], owners: &mut HashMap) { + use super::classes::template_key as key; + let names = |list: &[crate::names::Name]| list.iter().map(|n| key(n.as_str())).collect(); + let trait_names = |uses: &[crate::parser::ast::TraitUse]| { + uses.iter() + .flat_map(|u| u.trait_names.iter().map(|n| key(n.as_str()))) + .collect() + }; + let method_names = |methods: &[ClassMethod]| { + methods.iter().map(|m| m.name.to_ascii_lowercase()).collect() + }; + for stmt in stmts { + let (name, owner) = match &stmt.kind { + StmtKind::ClassDecl { name, extends, implements, trait_uses, methods, .. } => ( + name, + Owner { + is_interface: false, + parent: extends.as_ref().map(|n| key(n.as_str())), + interfaces: names(implements), + traits: trait_names(trait_uses), + methods: method_names(methods), + }, + ), + StmtKind::InterfaceDecl { name, extends, methods, .. } => ( + name, + Owner { + is_interface: true, + parent: None, + interfaces: names(extends), + traits: Vec::new(), + methods: method_names(methods), + }, + ), + StmtKind::TraitDecl { name, trait_uses, methods, .. } => ( + name, + Owner { + traits: trait_names(trait_uses), + methods: method_names(methods), + ..Owner::default() + }, + ), + StmtKind::EnumDecl { name, implements, trait_uses, methods, .. } => ( + name, + Owner { + interfaces: names(implements), + traits: trait_names(trait_uses), + methods: method_names(methods), + ..Owner::default() + }, + ), + StmtKind::NamespaceBlock { body, .. } | StmtKind::Synthetic(body) => { + index_owners(body, owners); + continue; + } + _ => continue, + }; + owners.insert(key(name), owner); + } +} + +/// Every class that uses `trait_key`, directly or through another trait. +fn users_of(trait_key: &str, users: &HashMap>) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![trait_key.to_string()]; + let mut seen = BTreeSet::new(); + while let Some(current) = stack.pop() { + for user in users.get(¤t).into_iter().flatten() { + if seen.insert(user.clone()) { + out.push(user.clone()); + // A trait that uses this trait has users of its own; a class has none, so + // following every user is exact and terminates on `seen`. + stack.push(user.clone()); + } + } + } + out +} + +/// Collects the methods every ancestor of `key` declares (its own only when `include_self`). +fn ancestors_methods( + key: &str, + owners: &HashMap, + out: &mut BTreeSet, + seen: &mut BTreeSet, + include_self: bool, +) { + if !seen.insert(key.to_string()) { + return; + } + let Some(owner) = owners.get(key) else { + // Not declared by the program: a built-in. Its methods are reached by dynamic dispatch + // as much as a user interface's are (`ArrayAccess::offsetGet` runs for `$bag["k"]`). + match builtin_methods(key) { + Some(methods) => out.extend(methods.iter().map(|m| m.to_string())), + // A built-in this table does not describe (an SPL or exception class, or a name + // from outside the program): every method may override one of its, so all are + // treated as dispatched. Erasure is php-src's own reading, so this only costs + // monomorphization. + None => { + out.insert(ANY_METHOD.to_string()); + } + } + return; + }; + if include_self { + flattened_methods(key, owners, out, &mut BTreeSet::new()); + } + for inherited in owner.parent.iter().chain(owner.interfaces.iter()) { + ancestors_methods(inherited, owners, out, seen, true); + } +} + +/// Stands for "every method" in a related-method set, when an ancestor is a built-in whose +/// methods are not known here. No PHP method name can contain `*`. +const ANY_METHOD: &str = "*"; + +/// The lower-cased methods of the built-in interfaces a user class commonly implements, by +/// template key, inherited interfaces included; `None` for any other undeclared name. +fn builtin_methods(key: &str) -> Option<&'static [&'static str]> { + const ITERATOR: &[&str] = &["current", "key", "next", "rewind", "valid"]; + Some(match key { + "traversable" => &[], + "iterator" => ITERATOR, + "iteratoraggregate" => &["getiterator"], + "arrayaccess" => &["offsetexists", "offsetget", "offsetset", "offsetunset"], + "countable" => &["count"], + "outeriterator" => &["current", "key", "next", "rewind", "valid", "getinneriterator"], + "recursiveiterator" => &["current", "key", "next", "rewind", "valid", "haschildren", "getchildren"], + "seekableiterator" => &["current", "key", "next", "rewind", "valid", "seek"], + "splobserver" => &["update"], + "splsubject" => &["attach", "detach", "notify"], + "stringable" => &["__tostring"], + "jsonserializable" => &["jsonserialize"], + "serializable" => &["serialize", "unserialize"], + "unitenum" => &["cases"], + "backedenum" => &["cases", "from", "tryfrom"], + "throwable" => &[ + "getmessage", "getcode", "getfile", "getline", "gettrace", "getprevious", + "gettraceasstring", "__tostring", + ], + _ => return None, + }) +} + +/// Collects the methods every descendant of `key` declares, traits included. +fn descendants_methods( + key: &str, + owners: &HashMap, + children: &HashMap>, + out: &mut BTreeSet, + seen: &mut BTreeSet, +) { + for child in children.get(key).into_iter().flatten() { + if seen.insert(child.clone()) { + flattened_methods(child, owners, out, &mut BTreeSet::new()); + descendants_methods(child, owners, children, out, seen); + } + } +} + +/// The methods `key` declares, plus those its traits contribute. +fn flattened_methods( + key: &str, + owners: &HashMap, + out: &mut BTreeSet, + seen: &mut BTreeSet, +) { + if !seen.insert(key.to_string()) { + return; + } + let Some(owner) = owners.get(key) else { return }; + out.extend(owner.methods.iter().cloned()); + for used in &owner.traits { + flattened_methods(used, owners, out, seen); + } +} + +/// Erases every dispatched generic method, and the PHPDoc class-parameter types of every +/// dispatched method of a PHPDoc template. +/// +/// The second half is what keeps a PHPDoc template's members compatible with the methods they +/// implement or override: `@param TValue $value` on `Collection::contains` would otherwise be a +/// native `int $value` in `Collection` and narrow `Enumerable::contains($value)`, a +/// signature php-src never sees. Native class parameters (`class Box`) are left alone. +fn erase_in(stmts: &mut [Stmt], dispatched: &BTreeSet<(String, String)>) { + for stmt in stmts.iter_mut() { + match &mut stmt.kind { + StmtKind::ClassDecl { name, methods, generics, .. } + | StmtKind::InterfaceDecl { name, methods, generics, .. } + | StmtKind::TraitDecl { name, methods, generics, .. } + | StmtKind::EnumDecl { name, methods, generics, .. } => { + let owner = super::classes::template_key(name); + let doc_class_params: Vec = generics + .iter() + .flat_map(|g| g.type_params.iter()) + .filter(|param| param.from_docblock) + .map(|param| param.name.clone()) + .collect(); + for method in methods.iter_mut() { + let key = (owner.clone(), method.name.to_ascii_lowercase()); + let is_dispatched = dispatched.contains(&key) + && !method.name.eq_ignore_ascii_case("__construct"); + if !method.type_params.is_empty() + && (method.is_abstract || !method.has_body || is_dispatched) + { + erase_method(method, &[]); + } + if is_dispatched && !doc_class_params.is_empty() { + erase_method(method, &doc_class_params); + } + } + } + StmtKind::NamespaceBlock { body, .. } | StmtKind::Synthetic(body) => { + erase_in(body, dispatched) + } + _ => {} + } + } +} + +/// Drops a method's own type parameters, and reads every one of them, and every name in +/// `class_params`, as `mixed` in the declared types: `array` keeps the `array` an +/// override is checked against, and a bare `T` (or `?T`, `T|mixed`) becomes untyped. +fn erase_method(method: &mut ClassMethod, class_params: &[String]) { + let mut names: Vec = method.type_params.iter().map(|p| p.name.clone()).collect(); + names.extend(class_params.iter().cloned()); + method.type_params.clear(); + let mixed = TypeExpr::Named(crate::names::Name::unqualified("mixed")); + let bindings: Vec<(String, TypeExpr)> = + names.iter().map(|name| (name.clone(), mixed.clone())).collect(); + let erase = |ty: &mut Option| { + if let Some(declared) = ty.as_mut() { + if declared.mentions_type_param(&names) { + let erased = collapse_mixed(declared.substitute_type_params(&bindings)); + // A position that erases to plain `mixed` was only ever the PHPDoc's: leaving it + // untyped is what php-src sees, and an untyped override stays compatible. + *ty = (erased != mixed).then_some(erased); + } + } + }; + for (_, declared, _, _) in method.params.iter_mut() { + erase(declared); + } + erase(&mut method.variadic_type); + erase(&mut method.return_type); +} + +/// Collapses `?mixed` and any union with a `mixed` member to `mixed`, which php-src spells only +/// that way (`?mixed` is a compile error there), and an array whose element types erased to +/// `mixed` (`array`, `mixed[]`) to the plain `array` php-src sees. +/// +/// The plain keyword matters beyond spelling: `array` is hash storage and `mixed[]` +/// packed storage, so either one gives the method a physical signature that differs from an +/// inherited `array` parameter, and the backend cannot adapt a dispatched call across that. +fn collapse_mixed(ty: TypeExpr) -> TypeExpr { + let is_mixed = |t: &TypeExpr| matches!(t, TypeExpr::Named(n) if n.as_str().eq_ignore_ascii_case("mixed")); + let plain_array = || TypeExpr::Named(crate::names::Name::unqualified("array")); + match ty { + TypeExpr::Nullable(inner) if is_mixed(&inner) => *inner, + TypeExpr::Nullable(inner) => TypeExpr::Nullable(Box::new(collapse_mixed(*inner))), + TypeExpr::Union(members) if members.iter().any(is_mixed) => { + TypeExpr::Named(crate::names::Name::unqualified("mixed")) + } + TypeExpr::Union(members) => { + TypeExpr::Union(members.into_iter().map(collapse_mixed).collect()) + } + TypeExpr::AssocArray { key, value } if is_mixed(&key) && is_mixed(&value) => plain_array(), + TypeExpr::Array(element) if is_mixed(&element) => plain_array(), + other => other, + } +} diff --git a/src/generics/variance.rs b/src/generics/variance.rs index 435f340d3a..5de43c2214 100644 --- a/src/generics/variance.rs +++ b/src/generics/variance.rs @@ -69,7 +69,40 @@ impl Polarity { } /// Rejects every template whose body breaks the promise its variance markers make. -pub fn verify(templates: &Templates) -> Result<(), CompileError> { +/// +/// A NATIVE marker (`+T`) that the body breaks is an error. A PHPDoc marker +/// (`@template-covariant`) is a claim php-src never checks, and real code (Laravel's +/// collections and paginators) declares it on templates that also consume the parameter. Such a +/// marker is demoted to invariant with a warning: widening through it would be unsound, and +/// rejecting the file would refuse a program PHP runs. +pub fn verify( + templates: &mut Templates, +) -> Result, CompileError> { + let mut warnings = Vec::new(); + loop { + let violation = match first_violation(templates) { + Ok(()) => return Ok(warnings), + Err(violation) => violation, + }; + let (key, param, from_docblock, error) = *violation; + if !from_docblock || !templates.demote_to_invariant(&key, ¶m) { + return Err(error); + } + warnings.push(crate::errors::CompileWarning::new( + error.span, + &format!( + "{}; the PHPDoc variance marker is ignored and the parameter is invariant", + error.message + ), + )); + } +} + +/// The first marker some template breaks: (template key, parameter, PHPDoc origin, error). +type Violation = Box<(String, String, bool, CompileError)>; + +/// Walks every marked parameter and reports the first one its declaration does not keep. +fn first_violation(templates: &Templates) -> Result<(), Violation> { // Variance is rare and the walk is not free, so the common program pays one map lookup. let markers = marker_table(templates); if markers.is_empty() { @@ -89,7 +122,15 @@ pub fn verify(templates: &Templates) -> Result<(), CompileError> { templates, &[], 0, - )?; + ) + .map_err(|error| { + Box::new(( + template_key(&template.declared_name), + param.name.clone(), + param.from_docblock, + error, + )) + })?; } } Ok(()) diff --git a/src/magic_constants/walker/mod.rs b/src/magic_constants/walker/mod.rs index ae1ce0e781..8168f02088 100644 --- a/src/magic_constants/walker/mod.rs +++ b/src/magic_constants/walker/mod.rs @@ -239,6 +239,11 @@ pub(crate) trait Pass { fn enter_class(&mut self, _name: &str) {} /// Called when leaving a class or interface declaration. fn leave_class(&mut self) {} + /// Called around the inheritance clauses (`extends`/`implements`) of the class or + /// interface just entered, so a pass can tell an inherited name from a member type. + fn enter_heritage(&mut self) {} + /// Called after the inheritance clauses of the class or interface just entered. + fn leave_heritage(&mut self) {} /// Called when entering a trait declaration. fn enter_trait(&mut self, _name: &str) {} /// Called when leaving a trait declaration. diff --git a/src/magic_constants/walker/stmts.rs b/src/magic_constants/walker/stmts.rs index 2e3bcc0659..36a9a779a9 100644 --- a/src/magic_constants/walker/stmts.rs +++ b/src/magic_constants/walker/stmts.rs @@ -32,9 +32,16 @@ fn walk_class_heritage( pass: &P, span: Span, ) -> (Option>, Option, Vec) { - let Some(generics) = generics else { - return (None, extends, implements); - }; + // A declaration with no generic half still NAMES its parents, and a bare generic parent + // (`class Model implements Arrayable` where `Arrayable` is a PHPDoc template) must reach the + // pass like any other mention, or it survives as the stripped template's name. + let generics = generics.unwrap_or_else(|| { + Box::new(GenericDecl { + type_params: Vec::new(), + extends_args: Vec::new(), + interface_args: Vec::new(), + }) + }); let GenericDecl { type_params, extends_args, @@ -63,9 +70,14 @@ fn walk_interface_heritage( pass: &P, span: Span, ) -> (Option>, Vec) { - let Some(generics) = generics else { - return (None, extends); - }; + // See `walk_class_heritage`: a bare generic parent must reach the pass too. + let generics = generics.unwrap_or_else(|| { + Box::new(GenericDecl { + type_params: Vec::new(), + extends_args: Vec::new(), + interface_args: Vec::new(), + }) + }); let GenericDecl { type_params, extends_args: _, @@ -390,8 +402,10 @@ pub(super) fn walk_stmt(stmt: Stmt, pass: &mut P) -> Stmt { constants, } => { pass.enter_class(&name); + pass.enter_heritage(); let (generics, extends, implements) = walk_class_heritage(generics, extends, implements, pass, span); + pass.leave_heritage(); let trait_uses = walk_trait_uses(trait_uses, pass, span); let new_properties = properties .into_iter() @@ -472,7 +486,9 @@ pub(super) fn walk_stmt(stmt: Stmt, pass: &mut P) -> Stmt { pass.enter_class(&name); // An interface has no single parent, so every inherited name — and every type // argument on one — lives in the `extends` list. + pass.enter_heritage(); let (generics, extends) = walk_interface_heritage(generics, extends, pass, span); + pass.leave_heritage(); let stmt = StmtKind::InterfaceDecl { name, generics, diff --git a/src/name_resolver/declarations.rs b/src/name_resolver/declarations.rs index 35d0357006..f08bc15f7d 100644 --- a/src/name_resolver/declarations.rs +++ b/src/name_resolver/declarations.rs @@ -67,6 +67,7 @@ fn resolve_type_params( .as_ref() .map(|ty| resolve_type_expr_with_type_params(ty, namespace, imports, symbols, type_params)), variance: param.variance, + from_docblock: param.from_docblock, }) .collect() } diff --git a/src/parser/ast/types.rs b/src/parser/ast/types.rs index 65e52b0e85..145611725f 100644 --- a/src/parser/ast/types.rs +++ b/src/parser/ast/types.rs @@ -10,7 +10,7 @@ use crate::names::Name; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] /// One declared type parameter (`T`, `T : Entity`, `K = string`). /// /// The name is a plain `String` rather than a `Name`: a type parameter resolves against its own @@ -36,8 +36,29 @@ pub struct TypeParam { /// WRITE: two instantiations are then simply different types, which is also what /// monomorphization makes them. pub variance: Variance, + /// Whether the parameter was declared by a PHPDoc `@template*` tag rather than native syntax. + /// + /// Read by the variance check, which demotes a PHPDoc variance marker the declaration + /// breaks to invariant (php-src never enforces one) instead of rejecting the file, and by + /// `crate::generics::dispatch`, which erases a PHPDoc template's class parameters from its + /// dynamically dispatched methods. Native parameters keep both checks strict. + /// Excluded from equality, so an annotated declaration and its native spelling stay the + /// same AST. + pub from_docblock: bool, +} + +impl PartialEq for TypeParam { + /// Compares the declaration, ignoring where it was spelled ([`TypeParam::from_docblock`]). + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.bound == other.bound + && self.default == other.default + && self.variance == other.variance + } } +impl Eq for TypeParam {} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] /// The subtyping a type parameter permits between two instantiations of its template. /// diff --git a/src/parser/stmt/params.rs b/src/parser/stmt/params.rs index 8881b13ecb..e174586a20 100644 --- a/src/parser/stmt/params.rs +++ b/src/parser/stmt/params.rs @@ -290,6 +290,7 @@ pub(crate) fn parse_type_param_list( bound, default, variance, + from_docblock: false, }); if *pos < tokens.len() && tokens[*pos].0 == Token::Comma { *pos += 1; diff --git a/src/types/checker/inference/expr/array_access.rs b/src/types/checker/inference/expr/array_access.rs index 78eeec097e..1065853496 100644 --- a/src/types/checker/inference/expr/array_access.rs +++ b/src/types/checker/inference/expr/array_access.rs @@ -77,6 +77,10 @@ impl Checker { PhpType::Object(class_name) => { if self.object_type_implements_interface(class_name, "ArrayAccess") { Ok(self.array_access_offset_get_type(class_name)) + } else if self.awaits_generic_instantiation(class_name) { + // `new Bag([...])` inferred `Bag` this round; the class + // is spliced by the next one, which checks this read properly. + Ok(PhpType::Mixed) } else { Err(CompileError::new(expr.span, "Cannot index non-array")) } diff --git a/src/types/checker/mixed_storage_scan.rs b/src/types/checker/mixed_storage_scan.rs index f99f64a1d3..005dfb4f51 100644 --- a/src/types/checker/mixed_storage_scan.rs +++ b/src/types/checker/mixed_storage_scan.rs @@ -699,7 +699,13 @@ fn collect_type_guarded_block( depth: u32, facts: &mut Facts, ) { - let guarded = type_guard_subject(condition); + // `instanceof