From f19153215ca8703691399c49ae43b1c19b6a5579 Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sun, 5 Jul 2026 13:56:59 -0500 Subject: [PATCH 1/3] fix: accept object-subtype default for object-typed params (defer subtype check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interface-typed constructor-promoted property (or param) with a concrete default — `public ComponentResolverInterface $r = new NullComponentResolver()` — was rejected: "Method parameter $r expects Object(I), got Object(N)". The default is validated in the purely-syntactic schema pass, where the class/interface table isn't populated yet, so type_accepts's class_implements_interface spuriously returns false. Accept an object default for an object-typed param there (mirroring the existing enum-case leniency); a genuine mismatch still surfaces at the call site. Byte-parity regression test. AIC epic #483 / EC-8 (#491), the object-param sub-part (~14 gaps: ward-forms AlteredForm/Field/... interface-typed promoted props). (cherry picked from commit db17c1762930e94dc866bf8e64d47db49a7b4a7b) --- src/types/checker/type_compat/declarations.rs | 10 ++++++++++ tests/codegen/objects/constructor_promotion.rs | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/types/checker/type_compat/declarations.rs b/src/types/checker/type_compat/declarations.rs index ba183a4dd4..0a25c2f544 100644 --- a/src/types/checker/type_compat/declarations.rs +++ b/src/types/checker/type_compat/declarations.rs @@ -185,6 +185,16 @@ impl Checker { ) -> Result<(), CompileError> { if let Some(default_expr) = default_expr { let default_ty = infer_expr_type_syntactic(default_expr); + // An object default for an object-typed parameter (e.g. an interface-typed promoted + // property with a concrete default, `I $x = new N()`) cannot be subtype-checked in + // this purely-syntactic schema pass — the class/interface table (and thus + // `class_implements_interface`) is not yet populated, so `type_accepts` would + // spuriously reject a valid subtype. Accept it here; a genuine mismatch surfaces at + // the call site / later semantic pass. (Mirrors the enum-case leniency above.) + if matches!(expected_ty, PhpType::Object(_)) && matches!(default_ty, PhpType::Object(_)) + { + return Ok(()); + } self.require_compatible_arg_type(expected_ty, &default_ty, span, context)?; } Ok(()) diff --git a/tests/codegen/objects/constructor_promotion.rs b/tests/codegen/objects/constructor_promotion.rs index 749651857f..cf87547f2f 100644 --- a/tests/codegen/objects/constructor_promotion.rs +++ b/tests/codegen/objects/constructor_promotion.rs @@ -157,3 +157,14 @@ echo $box->value; ); assert_eq!(out, "7:9"); } + +/// EC-8 (#491): an interface-typed constructor-promoted property with a concrete-subtype default +/// (`public I $x = new N()`) must be accepted — the syntactic schema pass has no class/interface +/// table to subtype-check, so it defers. Byte-parity vs PHP 8.5 (the default instantiates + dispatches). +#[test] +fn test_interface_promoted_prop_subtype_default() { + let out = compile_and_run( + "x->v();", + ); + assert_eq!(out, "n"); +} From a1faec4a14d902047877ef7d57d9e439ba2d6262 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Fri, 10 Jul 2026 08:38:10 +0200 Subject: [PATCH 2/3] fix: validate object defaults after schema completion --- src/types/checker/driver/mod.rs | 7 +- .../checker/schema/classes/properties.rs | 6 +- src/types/checker/schema/defaults.rs | 191 ++++++++++++++++++ src/types/checker/schema/mod.rs | 2 + src/types/checker/schema/validation.rs | 2 +- src/types/checker/type_compat/declarations.rs | 26 ++- .../codegen/objects/constructor_promotion.rs | 29 ++- tests/error_tests/type_system.rs | 15 ++ 8 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 src/types/checker/schema/defaults.rs diff --git a/src/types/checker/driver/mod.rs b/src/types/checker/driver/mod.rs index 8251863b4d..8c3d90cd6b 100644 --- a/src/types/checker/driver/mod.rs +++ b/src/types/checker/driver/mod.rs @@ -40,7 +40,7 @@ use super::builtin_stdclass::inject_builtin_stdclass; use super::builtin_user_filter::inject_builtin_user_filter; use super::schema::{ build_class_info_recursive, build_enum_info, build_interface_info_recursive, - drop_unresolvable_attribute_arg_refs, + drop_unresolvable_attribute_arg_refs, validate_deferred_object_defaults, }; use super::yield_validation::validate_yield_contexts; use super::Checker; @@ -256,6 +256,11 @@ pub(super) fn check_types_impl( } } } + errors.extend(validate_deferred_object_defaults( + &checker, + &flattened_classes, + program, + )); // All class/interface/enum metadata now exists, so deferred symbolic // attribute-argument references can be checked for resolvability. Drop any // the EIR backend cannot lower (e.g. built-in `Attribute::TARGET_CLASS`) so diff --git a/src/types/checker/schema/classes/properties.rs b/src/types/checker/schema/classes/properties.rs index 88d46ab65b..5287c1b435 100644 --- a/src/types/checker/schema/classes/properties.rs +++ b/src/types/checker/schema/classes/properties.rs @@ -94,7 +94,7 @@ fn apply_static_property( } let ty = if let Some(declared_ty) = declared_ty { - checker.validate_declared_default_type( + checker.validate_schema_declared_default_type( &declared_ty, prop.default.as_ref(), prop.span, @@ -248,7 +248,7 @@ fn apply_instance_property( } let ty = if let Some(declared_ty) = resolve_property_declared_type(checker, &class.name, prop)? { - checker.validate_declared_default_type( + checker.validate_schema_declared_default_type( &declared_ty, prop.default.as_ref(), prop.span, @@ -341,7 +341,7 @@ fn apply_instance_property_redeclaration( )?; let ty = if let Some(declared_ty) = declared_ty { - checker.validate_declared_default_type( + checker.validate_schema_declared_default_type( &declared_ty, prop.default.as_ref(), prop.span, diff --git a/src/types/checker/schema/defaults.rs b/src/types/checker/schema/defaults.rs new file mode 100644 index 0000000000..17be7dc2db --- /dev/null +++ b/src/types/checker/schema/defaults.rs @@ -0,0 +1,191 @@ +//! Purpose: +//! Revalidates object-typed declaration defaults after class-like schemas are complete. +//! Covers class/interface methods and declared instance/static property defaults. +//! +//! Called from: +//! - `crate::types::checker::driver::check_types_impl()` after enum schema construction. +//! +//! Key details: +//! - The initial schema pass cannot reliably resolve inheritance or interface relationships. +//! - Only deferred Object-to-Object pairs are revisited; scalar checks stay in the initial pass. + +use crate::errors::CompileError; +use crate::names::php_symbol_key; +use crate::parser::ast::{Expr, Program, StmtKind}; +use crate::types::{traits::FlattenedClass, FunctionSig, PhpType}; + +use super::super::{infer_expr_type_syntactic, Checker}; + +/// Validates every object-typed default deferred during class-like schema construction. +/// Returns all incompatibilities so the driver can aggregate them with other schema errors. +pub(crate) fn validate_deferred_object_defaults( + checker: &Checker, + flattened_classes: &[FlattenedClass], + program: &Program, +) -> Vec { + let mut errors = Vec::new(); + + for class in flattened_classes { + validate_class_defaults(checker, &class.name, &mut errors); + } + for stmt in program { + if let StmtKind::EnumDecl { name, .. } = &stmt.kind { + validate_class_defaults(checker, name, &mut errors); + } + } + + for stmt in program { + let StmtKind::InterfaceDecl { name, .. } = &stmt.kind else { + continue; + }; + let Some(interface_info) = checker.interfaces.get(name) else { + continue; + }; + let interface_key = php_symbol_key(name); + for method_key in &interface_info.method_order { + let is_declared_here = interface_info + .method_declaring_interfaces + .get(method_key) + .is_some_and(|declaring| php_symbol_key(declaring) == interface_key); + if !is_declared_here { + continue; + } + let Some(signature) = interface_info.methods.get(method_key) else { + continue; + }; + validate_signature_object_defaults(checker, signature, "Method", &mut errors); + } + } + + errors +} + +/// Revalidates local method and property defaults for one source-declared class or enum. +fn validate_class_defaults(checker: &Checker, class_name: &str, errors: &mut Vec) { + let Some(class_info) = checker.classes.get(class_name) else { + return; + }; + validate_class_property_defaults(checker, class_name, class_info, errors); + + for method in &class_info.method_decls { + let method_key = php_symbol_key(&method.name); + let signature = if method.is_static { + class_info.static_methods.get(&method_key) + } else { + class_info.methods.get(&method_key) + }; + let Some(signature) = signature else { + continue; + }; + validate_signature_object_defaults(checker, signature, "Method", errors); + } +} + +/// Revalidates local declared instance and static property defaults for one class. +fn validate_class_property_defaults( + checker: &Checker, + class_name: &str, + class_info: &crate::types::ClassInfo, + errors: &mut Vec, +) { + for (index, (property_name, expected_ty)) in class_info.properties.iter().enumerate() { + let is_local_declared_property = class_info.declared_properties.contains(property_name) + && class_info + .property_declaring_classes + .get(property_name) + .is_some_and(|declaring| declaring == class_name); + if !is_local_declared_property { + continue; + } + let Some(default) = class_info.defaults.get(index).and_then(Option::as_ref) else { + continue; + }; + validate_object_default( + checker, + expected_ty, + default, + &format!("Property {}::${} default", class_name, property_name), + errors, + ); + } + + for (index, (property_name, expected_ty)) in class_info.static_properties.iter().enumerate() { + let is_local_declared_property = class_info + .declared_static_properties + .contains(property_name) + && class_info + .static_property_declaring_classes + .get(property_name) + .is_some_and(|declaring| declaring == class_name); + if !is_local_declared_property { + continue; + } + let Some(default) = class_info + .static_defaults + .get(index) + .and_then(Option::as_ref) + else { + continue; + }; + validate_object_default( + checker, + expected_ty, + default, + &format!("Static property {}::${} default", class_name, property_name), + errors, + ); + } +} + +/// Revalidates object defaults for declared parameters in one resolved callable signature. +fn validate_signature_object_defaults( + checker: &Checker, + signature: &FunctionSig, + callable_kind: &str, + errors: &mut Vec, +) { + for (index, ((param_name, expected_ty), default)) in signature + .params + .iter() + .zip(signature.defaults.iter()) + .enumerate() + { + if !signature + .declared_params + .get(index) + .copied() + .unwrap_or(false) + { + continue; + } + let Some(default) = default.as_ref() else { + continue; + }; + validate_object_default( + checker, + expected_ty, + default, + &format!("{} parameter ${}", callable_kind, param_name), + errors, + ); + } +} + +/// Checks one deferred default when both its declared and syntactic types are objects. +fn validate_object_default( + checker: &Checker, + expected_ty: &PhpType, + default: &Expr, + context: &str, + errors: &mut Vec, +) { + let default_ty = infer_expr_type_syntactic(default); + if !matches!(expected_ty, PhpType::Object(_)) || !matches!(default_ty, PhpType::Object(_)) { + return; + } + if let Err(error) = + checker.require_compatible_arg_type(expected_ty, &default_ty, default.span, context) + { + errors.extend(error.flatten()); + } +} diff --git a/src/types/checker/schema/mod.rs b/src/types/checker/schema/mod.rs index 0e14b20774..f30a635970 100644 --- a/src/types/checker/schema/mod.rs +++ b/src/types/checker/schema/mod.rs @@ -10,11 +10,13 @@ pub(crate) mod validation; mod attribute_refs; +mod defaults; mod interfaces; mod classes; mod enums; pub(crate) use attribute_refs::drop_unresolvable_attribute_arg_refs; +pub(crate) use defaults::validate_deferred_object_defaults; pub(crate) use interfaces::*; pub(crate) use classes::*; pub(crate) use enums::*; diff --git a/src/types/checker/schema/validation.rs b/src/types/checker/schema/validation.rs index e92eb7e451..bb459cd709 100644 --- a/src/types/checker/schema/validation.rs +++ b/src/types/checker/schema/validation.rs @@ -62,7 +62,7 @@ pub(crate) fn build_method_sig( method.params.iter().zip(params.iter()) { if type_ann.is_some() { - checker.validate_declared_default_type( + checker.validate_schema_declared_default_type( resolved_ty, default.as_ref(), method.span, diff --git a/src/types/checker/type_compat/declarations.rs b/src/types/checker/type_compat/declarations.rs index 0a25c2f544..ec7b0415ef 100644 --- a/src/types/checker/type_compat/declarations.rs +++ b/src/types/checker/type_compat/declarations.rs @@ -185,19 +185,29 @@ impl Checker { ) -> Result<(), CompileError> { if let Some(default_expr) = default_expr { let default_ty = infer_expr_type_syntactic(default_expr); - // An object default for an object-typed parameter (e.g. an interface-typed promoted - // property with a concrete default, `I $x = new N()`) cannot be subtype-checked in - // this purely-syntactic schema pass — the class/interface table (and thus - // `class_implements_interface`) is not yet populated, so `type_accepts` would - // spuriously reject a valid subtype. Accept it here; a genuine mismatch surfaces at - // the call site / later semantic pass. (Mirrors the enum-case leniency above.) + self.require_compatible_arg_type(expected_ty, &default_ty, span, context)?; + } + Ok(()) + } + + /// Validates a declaration default while class-like schema metadata is still being built. + /// Object-to-object checks are deferred because inheritance and interface relationships are + /// incomplete during this phase; every other type pair is validated immediately. + pub(crate) fn validate_schema_declared_default_type( + &self, + expected_ty: &PhpType, + default_expr: Option<&Expr>, + span: crate::span::Span, + context: &str, + ) -> Result<(), CompileError> { + if let Some(default_expr) = default_expr { + let default_ty = infer_expr_type_syntactic(default_expr); if matches!(expected_ty, PhpType::Object(_)) && matches!(default_ty, PhpType::Object(_)) { return Ok(()); } - self.require_compatible_arg_type(expected_ty, &default_ty, span, context)?; } - Ok(()) + self.validate_declared_default_type(expected_ty, default_expr, span, context) } /// Builds the initial parameter type list for a function declaration, resolving type hints, diff --git a/tests/codegen/objects/constructor_promotion.rs b/tests/codegen/objects/constructor_promotion.rs index cf87547f2f..336d31e6e0 100644 --- a/tests/codegen/objects/constructor_promotion.rs +++ b/tests/codegen/objects/constructor_promotion.rs @@ -158,13 +158,30 @@ echo $box->value; assert_eq!(out, "7:9"); } -/// EC-8 (#491): an interface-typed constructor-promoted property with a concrete-subtype default -/// (`public I $x = new N()`) must be accepted — the syntactic schema pass has no class/interface -/// table to subtype-check, so it defers. Byte-parity vs PHP 8.5 (the default instantiates + dispatches). +/// Verifies that an interface-typed promoted property accepts a concrete implementing-class +/// default after schema construction, and that the default instance dispatches normally. #[test] -fn test_interface_promoted_prop_subtype_default() { +fn test_interface_promoted_property_accepts_object_default() { let out = compile_and_run( - "x->v();", + r#"v(); } +} +function read_value(I $x = new N()): string { return $x->v(); } +$c = new C(); +echo $c->x->v(); +echo ":"; +echo $c->read(); +echo ":"; +echo read_value(); +"#, ); - assert_eq!(out, "n"); + assert_eq!(out, "n:n:n"); } diff --git a/tests/error_tests/type_system.rs b/tests/error_tests/type_system.rs index 4711d2a200..146a5d1694 100644 --- a/tests/error_tests/type_system.rs +++ b/tests/error_tests/type_system.rs @@ -447,6 +447,21 @@ $box = new Box("bad"); ); } +/// Verifies that an unrelated object default is rejected after class relationships are known. +#[test] +fn test_error_promoted_property_rejects_incompatible_object_default() { + expect_error( + r#" Date: Fri, 10 Jul 2026 08:40:31 +0200 Subject: [PATCH 3/3] docs: document object subtype defaults --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2188836cbe..c226001765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to elephc, a PHP-to-native compiler written in Rust. Releases are listed newest first. ## [Unreleased] +- Object-subtype declaration defaults are now validated after class and interface schemas are complete: parameters, methods, and constructor-promoted properties may use an implementing class or subclass instance as the default for an interface/base-class type, while unrelated object defaults are still rejected with a type error. - `htmlspecialchars()` / `htmlentities()` now accept the optional `$flags` and `$encoding` arguments (issue #506), and the `ENT_*` constants (`ENT_QUOTES`, `ENT_COMPAT`, `ENT_NOQUOTES`, `ENT_HTML401`, `ENT_HTML5`, `ENT_XHTML`, `ENT_XML1`, `ENT_SUBSTITUTE`, `ENT_IGNORE`) are defined with PHP's values. The escaper currently always applies `ENT_QUOTES` behavior. - Static interface methods (PHP 8.3+): an interface may declare `public static function` signatures; a concrete implementing class must provide a compatible public static method (abstract classes may defer to a concrete child), dispatched by class with no vtable slot. `#[\Override]` is accepted on the static implementation, including when the interface is implemented by an abstract parent class. - Deprecated `${var}` / `${expr}` string interpolation is now accepted by the lexer (issue #340), matching PHP 8.x's deprecated-but-working behavior.