From 4d45d43ab4b4d7cd6a161b6ee895911e7d7745cd Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Fri, 10 Jul 2026 14:01:39 -0500 Subject: [PATCH 1/5] feat: parse typed class constants (PHP 8.3 `const TYPE NAME = ...`) --- src/parser/stmt/oop/body.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/parser/stmt/oop/body.rs b/src/parser/stmt/oop/body.rs index caa280b3da..07fc288638 100644 --- a/src/parser/stmt/oop/body.rs +++ b/src/parser/stmt/oop/body.rs @@ -228,6 +228,17 @@ pub(in crate::parser::stmt) fn parse_class_like_body( )); } *pos += 1; // consume `const` + // PHP 8.3 typed class constant: `const TYPE NAME = value`. An untyped + // const has its name directly before `=`; anything else before `=` is a + // declared type — speculatively parse and discard it (the annotation does + // not affect the value's runtime behaviour, and `ClassConst` carries no + // type, so byte-parity with PHP is preserved). + if !matches!(tokens.get(*pos + 1).map(|(t, _)| t), Some(Token::Assign)) { + let before_type = *pos; + if parse_type_expr(tokens, pos, member_span).is_err() { + *pos = before_type; + } + } // PHP 8 allows semi-reserved keywords as class-constant names, except `class`, // which is reserved for the `Foo::class` name fetch. let const_name = match tokens.get(*pos).map(|(t, _)| t) { @@ -663,6 +674,14 @@ fn parse_interface_body( } if tokens[*pos].0 == Token::Const { *pos += 1; // consume `const` + // PHP 8.3 typed class constant: `const TYPE NAME = value` — speculatively + // parse and discard a declared type before the name (see the class-body site). + if !matches!(tokens.get(*pos + 1).map(|(t, _)| t), Some(Token::Assign)) { + let before_type = *pos; + if parse_type_expr(tokens, pos, member_span).is_err() { + *pos = before_type; + } + } // PHP 8 allows semi-reserved keywords as class-constant names, except `class`, // which is reserved for the `Foo::class` name fetch. let const_name = match tokens.get(*pos).map(|(t, _)| t) { From acea864134e12b21e8bf8c575fb84914f46ae3e0 Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sun, 5 Jul 2026 21:42:42 -0500 Subject: [PATCH 2/5] test: typed class constants parse and read byte-identically --- tests/codegen/oop/constants.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/codegen/oop/constants.rs b/tests/codegen/oop/constants.rs index 3a046b7597..58365b2051 100644 --- a/tests/codegen/oop/constants.rs +++ b/tests/codegen/oop/constants.rs @@ -221,3 +221,13 @@ echo Derived::get() . "\n"; ); assert_eq!(out, "10\n20\n"); } + +/// PHP 8.3 typed class constants: `const TYPE NAME = ...` parses in class-like bodies and the +/// constants read back byte-identically to PHP 8.5. +#[test] +fn test_typed_class_constants_parse_and_read() { + let out = compile_and_run( + " Date: Tue, 14 Jul 2026 18:30:59 +0200 Subject: [PATCH 3/5] fix(types): complete typed class constant semantics --- .../reflection/owner_materialization.rs | 15 + src/codegen/lower_inst/objects/reflection.rs | 89 +++- src/codegen_support/runtime/data/user.rs | 1 + src/ir/module.rs | 3 + src/ir_lower/program.rs | 35 ++ src/ir_lower/tests/exhaustive.rs | 2 + src/name_resolver/declarations.rs | 4 + src/parser/ast/oop.rs | 14 +- src/parser/stmt/oop/body.rs | 44 +- .../checker/builtin_spl_classes/common.rs | 1 + .../checker/builtin_types/date_period.rs | 1 + src/types/checker/builtin_types/datetime.rs | 2 + src/types/checker/builtin_types/reflection.rs | 30 +- src/types/checker/driver/mod.rs | 34 +- .../checker/inference/expr/class_refs.rs | 22 +- src/types/checker/schema/class_constants.rs | 413 ++++++++++++++++++ src/types/checker/schema/classes/state.rs | 10 + src/types/checker/schema/enums.rs | 8 + src/types/checker/schema/interfaces.rs | 10 + src/types/checker/schema/mod.rs | 2 + src/types/schema.rs | 8 +- tests/codegen/oop/attributes.rs | 34 ++ tests/codegen/oop/constants.rs | 15 + tests/error_tests/classes_traits.rs | 49 +++ tests/parser_tests/classes/declarations.rs | 20 + 25 files changed, 828 insertions(+), 38 deletions(-) create mode 100644 src/types/checker/schema/class_constants.rs diff --git a/crates/elephc-magician/src/interpreter/reflection/owner_materialization.rs b/crates/elephc-magician/src/interpreter/reflection/owner_materialization.rs index b19efdccd6..85516279f0 100644 --- a/crates/elephc-magician/src/interpreter/reflection/owner_materialization.rs +++ b/crates/elephc-magician/src/interpreter/reflection/owner_materialization.rs @@ -214,6 +214,21 @@ pub(super) fn eval_reflection_owner_object_with_members( backing_value_cell, constructor, )?; + if owner_kind == EVAL_REFLECTION_OWNER_CLASS_CONSTANT { + let has_type = values.bool_value(type_metadata.is_some())?; + let type_value = match type_metadata { + Some(type_metadata) => eval_reflection_type_object_result(type_metadata, values)?, + None => values.null()?, + }; + eval_reflection_with_declaring_class_scope( + "ReflectionClassConstant", + context, + |_| -> Result<(), EvalStatus> { + values.property_set(object, "__has_type", has_type)?; + values.property_set(object, "__type", type_value) + }, + )?; + } if matches!( owner_kind, EVAL_REFLECTION_OWNER_CLASS | EVAL_REFLECTION_OWNER_OBJECT | EVAL_REFLECTION_OWNER_ENUM diff --git a/src/codegen/lower_inst/objects/reflection.rs b/src/codegen/lower_inst/objects/reflection.rs index a43ccb36a7..d9bb7323fa 100644 --- a/src/codegen/lower_inst/objects/reflection.rs +++ b/src/codegen/lower_inst/objects/reflection.rs @@ -88,6 +88,7 @@ struct ReflectionClassConstantMetadata { attr_names: Vec, attr_args: Vec>>, value: ReflectionConstantValue, + type_metadata: Option, visibility: Visibility, is_final: bool, } @@ -828,6 +829,15 @@ fn emit_reflection_owner_object( emit_reflection_owner_mixed_property_from_result(ctx, class_name, "__value")?; } } + if class_name == "ReflectionClassConstant" { + emit_reflection_owner_bool_property( + ctx, + class_name, + "__has_type", + metadata.type_metadata.is_some(), + )?; + emit_reflection_owner_type_property(ctx, class_name, metadata.type_metadata.as_ref())?; + } if class_name == "ReflectionEnumBackedCase" { if let Some(value) = &metadata.backing_value { abi::emit_push_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); @@ -1960,7 +1970,7 @@ fn reflection_class_constant_owner_metadata( backing_value: None, is_enum_case: false, parameter_members: Vec::new(), - type_metadata: None, + type_metadata: metadata.type_metadata, property_default_value: None, required_parameter_count: 0, is_deprecated: false, @@ -2008,6 +2018,10 @@ fn reflection_class_constant_lookup( .cloned() .unwrap_or_default(), value, + type_metadata: info + .constant_types + .get(constant_name) + .and_then(reflection_declared_type_metadata), visibility: info .constant_visibilities .get(constant_name) @@ -2050,6 +2064,12 @@ fn reflection_class_constant_lookup( attr_names: Vec::new(), attr_args: Vec::new(), value, + type_metadata: ctx + .module + .declared_trait_constant_types + .get(trait_name) + .and_then(|types| types.get(constant_name)) + .and_then(reflection_declared_type_metadata), visibility: ctx .module .declared_trait_constant_visibilities @@ -2092,6 +2112,10 @@ fn reflection_interface_class_constant_lookup( attr_names: Vec::new(), attr_args: Vec::new(), value, + type_metadata: info + .constant_types + .get(constant_name) + .and_then(reflection_declared_type_metadata), visibility: Visibility::Public, is_final, })) @@ -2643,6 +2667,7 @@ fn reflection_class_constant_reflection_members( enum_name: class_name.to_string(), case_name: case.name.clone(), }, + None, Visibility::Public, false, true, @@ -2677,6 +2702,10 @@ fn reflection_class_constant_reflection_members( .cloned() .unwrap_or_default(), value, + current_info + .constant_types + .get(constant_name) + .and_then(reflection_declared_type_metadata), current_info .constant_visibilities .get(constant_name) @@ -2780,6 +2809,10 @@ fn collect_interface_constant_reflection_members( Vec::new(), Vec::new(), value, + interface_info + .constant_types + .get(constant_name) + .and_then(reflection_declared_type_metadata), Visibility::Public, is_final, false, @@ -2809,6 +2842,11 @@ fn reflection_trait_constant_reflection_members( Vec::new(), Vec::new(), value, + ctx.module + .declared_trait_constant_types + .get(trait_name) + .and_then(|types| types.get(constant_name)) + .and_then(reflection_declared_type_metadata), ctx.module .declared_trait_constant_visibilities .get(trait_name) @@ -2831,6 +2869,7 @@ fn push_unique_constant_reflection_member( attr_names: Vec, attr_args: Vec>>, value: ReflectionConstantValue, + type_metadata: Option, visibility: Visibility, is_final: bool, is_enum_case: bool, @@ -2850,7 +2889,7 @@ fn push_unique_constant_reflection_member( is_enum_case, flags: reflection_member_flags(false, &visibility, is_final, false, false, false), modifiers: reflection_class_constant_modifiers(&visibility, is_final), - type_metadata: None, + type_metadata, default_value: None, property_hook_members: Vec::new(), required_parameter_count: 0, @@ -4306,6 +4345,39 @@ fn reflection_parameter_type_metadata( } } +/// Converts a retained declared class-constant type into reflection metadata. +fn reflection_declared_type_metadata( + type_expr: &TypeExpr, +) -> Option { + match type_expr { + TypeExpr::Nullable(inner) => { + let mut metadata = reflection_named_type_metadata_from_type_expr(inner)?; + metadata.allows_null = true; + Some(ReflectionParameterTypeMetadata::Named(metadata)) + } + TypeExpr::Union(members) => { + let allows_null = members.iter().any(|member| matches!(member, TypeExpr::Void)); + let types = members + .iter() + .filter(|member| !matches!(member, TypeExpr::Void)) + .map(reflection_named_type_metadata_from_type_expr) + .collect::>>()?; + if types.len() == 1 { + let mut metadata = types.into_iter().next()?; + metadata.allows_null = allows_null; + Some(ReflectionParameterTypeMetadata::Named(metadata)) + } else { + (!types.is_empty()).then_some(ReflectionParameterTypeMetadata::Union( + ReflectionUnionTypeMetadata { types, allows_null }, + )) + } + } + TypeExpr::Intersection(members) => reflection_intersection_type_metadata(members), + _ => reflection_named_type_metadata_from_type_expr(type_expr) + .map(ReflectionParameterTypeMetadata::Named), + } +} + /// Converts a declared return type into the supported `ReflectionType` subset. fn reflection_return_type_metadata(sig: &FunctionSig) -> Option { if !sig.declared_return { @@ -4398,15 +4470,17 @@ fn reflection_named_type_metadata_from_type_expr( TypeExpr::Int => Some(reflection_builtin_named_type("int", false)), TypeExpr::Float => Some(reflection_builtin_named_type("float", false)), TypeExpr::Bool => Some(reflection_builtin_named_type("bool", false)), + TypeExpr::False => Some(reflection_builtin_named_type("false", false)), TypeExpr::Str => Some(reflection_builtin_named_type("string", false)), TypeExpr::Iterable => Some(reflection_builtin_named_type("iterable", false)), TypeExpr::Array(_) => Some(reflection_builtin_named_type("array", false)), TypeExpr::Named(name) => { let raw_name = name.as_str().trim_start_matches('\\'); match raw_name.to_ascii_lowercase().as_str() { - "array" | "callable" | "mixed" | "object" => { + "array" | "callable" | "object" => { Some(reflection_builtin_named_type(raw_name, false)) } + "mixed" => Some(reflection_builtin_named_type(raw_name, true)), _ => Some(ReflectionNamedTypeMetadata { name: raw_name.to_string(), allows_null: false, @@ -6371,6 +6445,15 @@ fn emit_reflection_member_object( &property_string, )?; } + if member_class_name == "ReflectionClassConstant" { + emit_reflection_owner_bool_property( + ctx, + member_class_name, + "__has_type", + member.type_metadata.is_some(), + )?; + emit_reflection_owner_type_property(ctx, member_class_name, member.type_metadata.as_ref())?; + } if matches!( member_class_name, "ReflectionClassConstant" | "ReflectionEnumUnitCase" | "ReflectionEnumBackedCase" diff --git a/src/codegen_support/runtime/data/user.rs b/src/codegen_support/runtime/data/user.rs index 248f39e7b8..516ed9f3eb 100644 --- a/src/codegen_support/runtime/data/user.rs +++ b/src/codegen_support/runtime/data/user.rs @@ -2370,6 +2370,7 @@ mod tests { is_readonly_class: false, allow_dynamic_properties: false, constants: HashMap::new(), + constant_types: HashMap::new(), constant_visibilities: HashMap::new(), final_constants: HashSet::new(), attribute_names: Vec::new(), diff --git a/src/ir/module.rs b/src/ir/module.rs index e55a526edf..57a5bdc790 100644 --- a/src/ir/module.rs +++ b/src/ir/module.rs @@ -75,6 +75,8 @@ pub struct Module { pub declared_trait_property_names: HashMap>, pub declared_trait_constant_names: HashMap>, pub declared_trait_constants: HashMap>, + pub declared_trait_constant_types: + HashMap>, pub declared_trait_constant_visibilities: HashMap>, pub declared_trait_final_constants: HashMap>, /// Prescanned global constant values used by EIR lowering and eval metadata registration. @@ -119,6 +121,7 @@ impl Module { declared_trait_property_names: HashMap::new(), declared_trait_constant_names: HashMap::new(), declared_trait_constants: HashMap::new(), + declared_trait_constant_types: HashMap::new(), declared_trait_constant_visibilities: HashMap::new(), declared_trait_final_constants: HashMap::new(), global_constants: HashMap::new(), diff --git a/src/ir_lower/program.rs b/src/ir_lower/program.rs index ff76839262..198464c160 100644 --- a/src/ir_lower/program.rs +++ b/src/ir_lower/program.rs @@ -107,6 +107,7 @@ fn populate_metadata(module: &mut Module, program: &Program, check_result: &Chec module.declared_trait_property_names = collect_declared_trait_property_names(program); module.declared_trait_constant_names = collect_declared_trait_constant_names(program); module.declared_trait_constants = collect_declared_trait_constants(program); + module.declared_trait_constant_types = collect_declared_trait_constant_types(program); module.declared_trait_constant_visibilities = collect_declared_trait_constant_visibilities(program); module.declared_trait_final_constants = collect_declared_trait_final_constants(program); @@ -1367,6 +1368,40 @@ fn collect_declared_trait_constants(program: &Program) -> HashMap HashMap> { + let mut types = HashMap::new(); + for stmt in program { + match &stmt.kind { + StmtKind::TraitDecl { + name, + constants: trait_constants, + .. + } => { + types.insert( + name.clone(), + trait_constants + .iter() + .filter_map(|constant| { + constant + .type_expr + .clone() + .map(|type_expr| (constant.name.clone(), type_expr)) + }) + .collect(), + ); + } + StmtKind::NamespaceBlock { body, .. } => { + types.extend(collect_declared_trait_constant_types(body)); + } + _ => {} + } + } + types +} + /// Collects direct PHP constant visibilities declared by each trait. fn collect_declared_trait_constant_visibilities( program: &Program, diff --git a/src/ir_lower/tests/exhaustive.rs b/src/ir_lower/tests/exhaustive.rs index 1ed84995a3..880f34e0d6 100644 --- a/src/ir_lower/tests/exhaustive.rs +++ b/src/ir_lower/tests/exhaustive.rs @@ -168,6 +168,7 @@ fn class_info(_class_name: &str) -> ClassInfo { is_readonly_class: false, allow_dynamic_properties: true, constants: HashMap::new(), + constant_types: HashMap::new(), constant_visibilities: Default::default(), final_constants: Default::default(), attribute_names: Vec::new(), @@ -381,6 +382,7 @@ fn lowers_every_stmt_variant_smoke() { name: "K".to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: int(1), span: sp(), attributes: Vec::new(), diff --git a/src/name_resolver/declarations.rs b/src/name_resolver/declarations.rs index 4791332f54..566449bbb9 100644 --- a/src/name_resolver/declarations.rs +++ b/src/name_resolver/declarations.rs @@ -351,6 +351,10 @@ fn resolve_class_consts( constants .iter() .map(|constant| ClassConst { + type_expr: constant + .type_expr + .as_ref() + .map(|ty| resolve_type_expr(ty, namespace, imports, symbols)), value: resolve_expr(&constant.value, namespace, imports, symbols), attributes: resolve_attribute_groups(&constant.attributes, namespace, imports, symbols), ..constant.clone() diff --git a/src/parser/ast/oop.rs b/src/parser/ast/oop.rs index bc88e95fee..9aba0c64de 100644 --- a/src/parser/ast/oop.rs +++ b/src/parser/ast/oop.rs @@ -175,15 +175,16 @@ impl PartialEq for ClassProperty { } } -/// `const NAME = expr;` declaration inside a class/interface/trait body. -/// PHP supports per-constant visibility (PHP 7.1+) and the `final` -/// modifier (PHP 8.1+). Per-constant attributes are stored for future -/// `#[\Deprecated]` support. +/// `const [TYPE] NAME = expr;` declaration inside a class/interface/trait body. +/// PHP supports per-constant visibility (PHP 7.1+), the `final` modifier +/// (PHP 8.1+), and declared types (PHP 8.3+). Per-constant attributes are +/// retained for reflection. #[derive(Debug, Clone)] pub struct ClassConst { pub name: String, pub visibility: Visibility, pub is_final: bool, + pub type_expr: Option, pub value: Expr, #[allow(dead_code)] // Used for error reporting in future passes pub span: Span, @@ -192,12 +193,13 @@ pub struct ClassConst { } impl PartialEq for ClassConst { - /// Compares class constants by name, visibility, is_final, value, and attributes; - /// span is not compared. + /// Compares class constants by name, visibility, final/type metadata, value, and + /// attributes; span is not compared. fn eq(&self, other: &Self) -> bool { self.name == other.name && self.visibility == other.visibility && self.is_final == other.is_final + && self.type_expr == other.type_expr && self.value == other.value && self.attributes == other.attributes } diff --git a/src/parser/stmt/oop/body.rs b/src/parser/stmt/oop/body.rs index 522e9f895a..42ae45d9ab 100644 --- a/src/parser/stmt/oop/body.rs +++ b/src/parser/stmt/oop/body.rs @@ -228,17 +228,7 @@ pub(in crate::parser::stmt) fn parse_class_like_body( )); } *pos += 1; // consume `const` - // PHP 8.3 typed class constant: `const TYPE NAME = value`. An untyped - // const has its name directly before `=`; anything else before `=` is a - // declared type — speculatively parse and discard it (the annotation does - // not affect the value's runtime behaviour, and `ClassConst` carries no - // type, so byte-parity with PHP is preserved). - if !matches!(tokens.get(*pos + 1).map(|(t, _)| t), Some(Token::Assign)) { - let before_type = *pos; - if parse_type_expr(tokens, pos, member_span).is_err() { - *pos = before_type; - } - } + let type_expr = parse_optional_class_const_type(tokens, pos, member_span); // PHP 8 allows semi-reserved keywords as class-constant names, except `class`, // which is reserved for the `Foo::class` name fetch. let const_name = match tokens.get(*pos).map(|(t, _)| t) { @@ -278,6 +268,7 @@ pub(in crate::parser::stmt) fn parse_class_like_body( name: const_name, visibility: modifiers.visibility, is_final: modifiers.is_final, + type_expr, value, span: member_span, attributes: member_attributes, @@ -470,6 +461,27 @@ fn parse_optional_property_type( Ok(Some(parse_type_expr(tokens, pos, span)?)) } +/// Parses the optional PHP 8.3 type between `const` and a class-constant name. +/// A token followed immediately by `=` is the untyped constant name, including +/// semi-reserved names such as `string`. +fn parse_optional_class_const_type( + tokens: &[(Token, Span)], + pos: &mut usize, + span: Span, +) -> Option { + if matches!(tokens.get(*pos + 1).map(|(token, _)| token), Some(Token::Assign)) { + return None; + } + let before_type = *pos; + match parse_type_expr(tokens, pos, span) { + Ok(type_expr) => Some(type_expr), + Err(_) => { + *pos = before_type; + None + } + } +} + /// Holds parsed member modifiers for class-like members: visibility, static, readonly, abstract, final. /// Used internally during class-like body parsing to collect modifiers before processing a member declaration. pub(super) struct MemberModifiers { @@ -693,14 +705,7 @@ fn parse_interface_body( } if tokens[*pos].0 == Token::Const { *pos += 1; // consume `const` - // PHP 8.3 typed class constant: `const TYPE NAME = value` — speculatively - // parse and discard a declared type before the name (see the class-body site). - if !matches!(tokens.get(*pos + 1).map(|(t, _)| t), Some(Token::Assign)) { - let before_type = *pos; - if parse_type_expr(tokens, pos, member_span).is_err() { - *pos = before_type; - } - } + let type_expr = parse_optional_class_const_type(tokens, pos, member_span); // PHP 8 allows semi-reserved keywords as class-constant names, except `class`, // which is reserved for the `Foo::class` name fetch. let const_name = match tokens.get(*pos).map(|(t, _)| t) { @@ -740,6 +745,7 @@ fn parse_interface_body( name: const_name, visibility: modifiers.visibility, is_final: modifiers.is_final, + type_expr, value, span: member_span, attributes: member_attributes, diff --git a/src/types/checker/builtin_spl_classes/common.rs b/src/types/checker/builtin_spl_classes/common.rs index 3ba9138b42..8958d4693e 100644 --- a/src/types/checker/builtin_spl_classes/common.rs +++ b/src/types/checker/builtin_spl_classes/common.rs @@ -209,6 +209,7 @@ pub(super) fn class_const(name: &str, value: i64) -> ClassConst { name: name.to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: int_expr(value), span: crate::span::Span::dummy(), attributes: Vec::new(), diff --git a/src/types/checker/builtin_types/date_period.rs b/src/types/checker/builtin_types/date_period.rs index 6ee4eb622d..d1d85cac6c 100644 --- a/src/types/checker/builtin_types/date_period.rs +++ b/src/types/checker/builtin_types/date_period.rs @@ -248,6 +248,7 @@ fn class_const(name: &str, value: i64) -> ClassConst { name: name.to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: int_lit(value), span: dummy(), attributes: Vec::new(), diff --git a/src/types/checker/builtin_types/datetime.rs b/src/types/checker/builtin_types/datetime.rs index e2e9be1779..cc14bf7e3a 100644 --- a/src/types/checker/builtin_types/datetime.rs +++ b/src/types/checker/builtin_types/datetime.rs @@ -33,6 +33,7 @@ fn str_class_const(name: &str, value: &str) -> ClassConst { name: name.to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: Expr::new(ExprKind::StringLiteral(value.to_string()), dummy()), span: dummy(), attributes: Vec::new(), @@ -45,6 +46,7 @@ fn int_class_const(name: &str, value: i64) -> ClassConst { name: name.to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: Expr::new(ExprKind::IntLiteral(value), dummy()), span: dummy(), attributes: Vec::new(), diff --git a/src/types/checker/builtin_types/reflection.rs b/src/types/checker/builtin_types/reflection.rs index 4b27e7e033..a3c200b2b8 100644 --- a/src/types/checker/builtin_types/reflection.rs +++ b/src/types/checker/builtin_types/reflection.rs @@ -2053,6 +2053,7 @@ fn builtin_class_const(name: &str, value: i64) -> ClassConst { name: name.to_string(), visibility: Visibility::Public, is_final: false, + type_expr: None, value: Expr::new(ExprKind::IntLiteral(value), crate::span::Span::dummy()), span: crate::span::Span::dummy(), attributes: Vec::new(), @@ -3994,8 +3995,33 @@ fn builtin_reflection_owner_class( methods.push(builtin_reflection_constant_false_bool_method( "isDeprecated", )); - methods.push(builtin_reflection_constant_false_bool_method("hasType")); - methods.push(builtin_reflection_constant_null_mixed_method("getType")); + if name == "ReflectionClassConstant" { + properties.push(builtin_property( + "__has_type", + Visibility::Private, + Some(TypeExpr::Bool), + bool_lit(false), + )); + properties.push(builtin_property( + "__type", + Visibility::Private, + Some(mixed_type()), + null_expr(), + )); + methods.push(builtin_reflection_slot_getter( + "hasType", + "__has_type", + TypeExpr::Bool, + )); + methods.push(builtin_reflection_slot_getter( + "getType", + "__type", + mixed_type(), + )); + } else { + methods.push(builtin_reflection_constant_false_bool_method("hasType")); + methods.push(builtin_reflection_constant_null_mixed_method("getType")); + } } if matches!(name, "ReflectionFunction" | "ReflectionMethod") { properties.push(builtin_property( diff --git a/src/types/checker/driver/mod.rs b/src/types/checker/driver/mod.rs index 531d9b064b..2ef98e911a 100644 --- a/src/types/checker/driver/mod.rs +++ b/src/types/checker/driver/mod.rs @@ -39,7 +39,8 @@ 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, validate_deferred_object_defaults, + drop_unresolvable_attribute_arg_refs, validate_deferred_class_constants, + validate_deferred_object_defaults, }; use super::yield_validation::validate_yield_contexts; use super::Checker; @@ -128,7 +129,9 @@ pub(super) fn check_types_impl( // An interface has no single parent class, so `self`/`static` resolve to the interface // itself; `parent` is left untouched (it is meaningless in an interface contract). let mut interface_methods = methods.clone(); + let mut interface_constants = constants.clone(); substitute_relative_class_types_in_methods(&mut interface_methods, name, None); + substitute_relative_class_types_in_constants(&mut interface_constants, name, None); interface_map.insert( name.clone(), InterfaceDeclInfo { @@ -140,7 +143,7 @@ pub(super) fn check_types_impl( properties: properties.clone(), methods: interface_methods, span: stmt.span, - constants: constants.clone(), + constants: interface_constants, }, ); } @@ -274,6 +277,13 @@ pub(super) fn check_types_impl( &flattened_classes, program, )); + errors.extend(validate_deferred_class_constants( + &mut checker, + &flattened_classes, + &interface_map, + &flattened_enums, + 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 @@ -535,6 +545,11 @@ fn substitute_relative_class_types_in_flattened(classes: &mut [FlattenedClass]) *ty = ty.substitute_relative_class_types(&self_class, parent_ref); } } + substitute_relative_class_types_in_constants( + &mut class.constants, + &self_class, + parent_ref, + ); } } @@ -543,6 +558,21 @@ fn substitute_relative_class_types_in_flattened_enums(enums: &mut HashMap, +) { + for constant in constants { + if let Some(type_expr) = constant.type_expr.as_mut() { + *type_expr = type_expr.substitute_relative_class_types(self_class, parent); + } } } diff --git a/src/types/checker/inference/expr/class_refs.rs b/src/types/checker/inference/expr/class_refs.rs index e11f86da3b..a30935b4a0 100644 --- a/src/types/checker/inference/expr/class_refs.rs +++ b/src/types/checker/inference/expr/class_refs.rs @@ -87,6 +87,9 @@ impl Checker { let mut current_class = Some(class_name.clone()); while let Some(cn) = current_class.as_deref() { if let Some(info) = self.classes.get(cn) { + if let Some(type_expr) = info.constant_types.get(name).cloned() { + return self.resolve_type_expr(&type_expr, expr.span); + } if let Some(value_expr) = info.constants.get(name).cloned() { return self.infer_type(&value_expr, &TypeEnv::default()); } @@ -96,13 +99,19 @@ impl Checker { // Fallback: search implemented interfaces (and parent interfaces). if let Some(class_info) = self.classes.get(&class_name).cloned() { for iface_name in &class_info.interfaces { - if let Some(value) = self.lookup_interface_constant(iface_name, name) { + if let Some((value, type_expr)) = self.lookup_interface_constant(iface_name, name) { + if let Some(type_expr) = type_expr { + return self.resolve_type_expr(&type_expr, expr.span); + } return self.infer_type(&value, &TypeEnv::default()); } } } // Direct interface receiver (`Limits::MAX`). - if let Some(value) = self.lookup_interface_constant(&class_name, name) { + if let Some((value, type_expr)) = self.lookup_interface_constant(&class_name, name) { + if let Some(type_expr) = type_expr { + return self.resolve_type_expr(&type_expr, expr.span); + } return self.infer_type(&value, &TypeEnv::default()); } // On an enum, a `::name` that is neither a declared case nor a constant is an undefined @@ -128,12 +137,12 @@ impl Checker { } /// Looks up a constant by name on an interface, traversing parent interfaces breadth-first - /// to find it. Returns the constant's value expression if found. + /// to find it. Returns its value expression and optional declared type. fn lookup_interface_constant( &self, interface_name: &str, const_name: &str, - ) -> Option { + ) -> Option<(crate::parser::ast::Expr, Option)> { let mut visited = std::collections::HashSet::new(); let mut queue: Vec = vec![interface_name.to_string()]; while let Some(name) = queue.pop() { @@ -142,7 +151,10 @@ impl Checker { } if let Some(iface) = self.interfaces.get(&name) { if let Some(value) = iface.constants.get(const_name) { - return Some(value.clone()); + return Some(( + value.clone(), + iface.constant_types.get(const_name).cloned(), + )); } queue.extend(iface.parents.iter().cloned()); } diff --git a/src/types/checker/schema/class_constants.rs b/src/types/checker/schema/class_constants.rs new file mode 100644 index 0000000000..37332def25 --- /dev/null +++ b/src/types/checker/schema/class_constants.rs @@ -0,0 +1,413 @@ +//! Purpose: +//! Validates PHP 8.3 typed class-constant declarations after class-like schemas exist. +//! Checks declared types, initializer values, and covariant inheritance contracts. +//! +//! Called from: +//! - `crate::types::checker::driver::check_types_impl()` after enum schema construction. +//! +//! Key details: +//! - Validation is deferred so object/interface relationships and scoped constant values resolve. +//! - Precise initializer types are strict apart from PHP's allowed int-to-float widening. +//! - Conservative `Mixed` inference is accepted when an expression cannot be narrowed statically. + +use std::collections::HashMap; + +use crate::errors::CompileError; +use crate::parser::ast::{ClassConst, Program, StmtKind, TypeExpr}; +use crate::types::checker::builtin_types::InterfaceDeclInfo; +use crate::types::traits::FlattenedClass; +use crate::types::{PhpType, TypeEnv}; + +use super::super::Checker; + +/// Validates typed constants declared by classes, interfaces, enums, and traits. +/// Returns every independent error so the checker can report the complete schema failure. +pub(crate) fn validate_deferred_class_constants( + checker: &mut Checker, + flattened_classes: &[FlattenedClass], + interface_map: &HashMap, + flattened_enums: &HashMap, + program: &Program, +) -> Vec { + let mut errors = Vec::new(); + + for class in flattened_classes { + for constant in &class.constants { + validate_constant_declaration(checker, &class.name, constant, &mut errors); + validate_class_constant_contract(checker, class, constant, &mut errors); + } + } + + for interface in interface_map.values() { + for constant in &interface.constants { + validate_constant_declaration(checker, &interface.name, constant, &mut errors); + validate_interface_constant_contract( + checker, + &interface.name, + &interface.extends, + constant, + &mut errors, + ); + } + } + + for enum_unit in flattened_enums.values() { + for constant in &enum_unit.constants { + validate_constant_declaration(checker, &enum_unit.name, constant, &mut errors); + validate_interface_contracts_for_constant( + checker, + &enum_unit.name, + &enum_unit.implements, + constant, + &mut errors, + ); + } + } + + validate_trait_constants(checker, program, &mut errors); + errors +} + +/// Validates one constant's allowed type and initializer value. +fn validate_constant_declaration( + checker: &mut Checker, + owner: &str, + constant: &ClassConst, + errors: &mut Vec, +) { + let Some(type_expr) = &constant.type_expr else { + return; + }; + let expected = match resolve_constant_type(checker, owner, constant, type_expr) { + Ok(expected) => expected, + Err(error) => { + errors.extend(error.flatten()); + return; + } + }; + let value = constant_value(checker, owner, &constant.name) + .unwrap_or_else(|| constant.value.clone()); + let previous_class = checker.current_class.replace(owner.to_string()); + let actual = checker.infer_type(&value, &TypeEnv::default()); + checker.current_class = previous_class; + match actual { + Ok(actual) if strict_type_accepts(checker, &expected, &actual, true) => {} + Ok(actual) => errors.push(CompileError::new( + constant.span, + &format!( + "Cannot use {} as value for class constant {}::{} of type {}", + actual, owner, constant.name, expected + ), + )), + Err(error) => errors.extend(error.flatten()), + } +} + +/// Resolves a declared constant type after rejecting PHP-forbidden type atoms. +fn resolve_constant_type( + checker: &Checker, + owner: &str, + constant: &ClassConst, + type_expr: &TypeExpr, +) -> Result { + if let Some(forbidden) = forbidden_constant_type(type_expr) { + return Err(CompileError::new( + constant.span, + &format!( + "Class constant {}::{} cannot have type {}", + owner, constant.name, forbidden + ), + )); + } + checker.resolve_type_expr(type_expr, constant.span) +} + +/// Returns the first `void`, `never`, or `callable` atom forbidden by PHP for constants. +fn forbidden_constant_type(type_expr: &TypeExpr) -> Option<&'static str> { + match type_expr { + TypeExpr::Void => Some("void"), + TypeExpr::Never => Some("never"), + TypeExpr::Named(name) => match name.as_str().to_ascii_lowercase().as_str() { + "void" => Some("void"), + "never" => Some("never"), + "callable" => Some("callable"), + _ => None, + }, + TypeExpr::Nullable(inner) | TypeExpr::Array(inner) | TypeExpr::Buffer(inner) => { + forbidden_constant_type(inner) + } + TypeExpr::Union(members) | TypeExpr::Intersection(members) => { + members.iter().find_map(forbidden_constant_type) + } + _ => None, + } +} + +/// Validates one direct class constant against parent-class and interface declarations. +fn validate_class_constant_contract( + checker: &Checker, + class: &FlattenedClass, + constant: &ClassConst, + errors: &mut Vec, +) { + if let Some(parent_name) = &class.extends { + if let Some((declaring, parent_type)) = + lookup_class_constant_contract(checker, parent_name, &constant.name) + { + validate_override_type(checker, &class.name, constant, &declaring, parent_type, errors); + } + } + validate_interface_contracts_for_constant( + checker, + &class.name, + &class.implements, + constant, + errors, + ); +} + +/// Validates one direct interface constant against every directly extended parent contract. +fn validate_interface_constant_contract( + checker: &Checker, + interface_name: &str, + parents: &[String], + constant: &ClassConst, + errors: &mut Vec, +) { + for parent_name in parents { + let Some(parent_info) = checker.interfaces.get(parent_name) else { + continue; + }; + if !parent_info.constants.contains_key(&constant.name) { + continue; + } + let declaring = parent_info + .constant_declaring_interfaces + .get(&constant.name) + .map(String::as_str) + .unwrap_or(parent_name); + validate_override_type( + checker, + interface_name, + constant, + declaring, + parent_info.constant_types.get(&constant.name), + errors, + ); + } +} + +/// Validates a class-like constant against matching constants on implemented interfaces. +fn validate_interface_contracts_for_constant( + checker: &Checker, + owner: &str, + interfaces: &[String], + constant: &ClassConst, + errors: &mut Vec, +) { + for interface_name in interfaces { + let Some(interface_info) = checker.interfaces.get(interface_name) else { + continue; + }; + if !interface_info.constants.contains_key(&constant.name) { + continue; + } + let declaring = interface_info + .constant_declaring_interfaces + .get(&constant.name) + .map(String::as_str) + .unwrap_or(interface_name); + validate_override_type( + checker, + owner, + constant, + declaring, + interface_info.constant_types.get(&constant.name), + errors, + ); + } +} + +/// Validates covariance against one inherited typed constant contract. +fn validate_override_type( + checker: &Checker, + owner: &str, + constant: &ClassConst, + inherited_owner: &str, + inherited_type_expr: Option<&TypeExpr>, + errors: &mut Vec, +) { + let Some(inherited_type_expr) = inherited_type_expr else { + return; + }; + let inherited = match checker.resolve_type_expr(inherited_type_expr, constant.span) { + Ok(inherited) => inherited, + Err(error) => { + errors.extend(error.flatten()); + return; + } + }; + let Some(type_expr) = &constant.type_expr else { + errors.push(incompatible_override_error( + owner, + constant, + inherited_owner, + &inherited, + )); + return; + }; + let child = match resolve_constant_type(checker, owner, constant, type_expr) { + Ok(child) => child, + Err(_) => return, + }; + if !strict_type_accepts(checker, &inherited, &child, false) { + errors.push(incompatible_override_error( + owner, + constant, + inherited_owner, + &inherited, + )); + } +} + +/// Builds the PHP-compatible diagnostic for a non-covariant constant override. +fn incompatible_override_error( + owner: &str, + constant: &ClassConst, + inherited_owner: &str, + inherited: &PhpType, +) -> CompileError { + CompileError::new( + constant.span, + &format!( + "Type of {}::{} must be compatible with {}::{} of type {}", + owner, constant.name, inherited_owner, constant.name, inherited + ), + ) +} + +/// Finds the nearest class declaration of a constant and its optional declared type. +fn lookup_class_constant_contract<'a>( + checker: &'a Checker, + class_name: &str, + constant_name: &str, +) -> Option<(String, Option<&'a TypeExpr>)> { + let mut current = Some(class_name.to_string()); + while let Some(name) = current { + let info = checker.classes.get(&name)?; + if info.constants.contains_key(constant_name) { + return Some((name, info.constant_types.get(constant_name))); + } + current = info.parent.clone(); + } + None +} + +/// Returns the schema-normalized value expression for one class-like constant. +fn constant_value( + checker: &Checker, + owner: &str, + constant_name: &str, +) -> Option { + checker + .classes + .get(owner) + .and_then(|info| info.constants.get(constant_name)) + .or_else(|| { + checker + .interfaces + .get(owner) + .and_then(|info| info.constants.get(constant_name)) + }) + .cloned() +} + +/// Returns whether `expected` accepts every value represented by `actual` without coercion. +/// `allow_int_to_float` is enabled only for initializer values, never for variance checks. +fn strict_type_accepts( + checker: &Checker, + expected: &PhpType, + actual: &PhpType, + allow_int_to_float: bool, +) -> bool { + if expected == actual || matches!(expected, PhpType::Mixed) { + return true; + } + if allow_int_to_float && matches!(actual, PhpType::Mixed) { + // Expression inference intentionally uses Mixed for operations whose exact + // compile-time value is narrower (for example `self::INT_CONST * 3`). + return true; + } + match (expected, actual) { + (PhpType::Bool, PhpType::False) => true, + (PhpType::Float, PhpType::Int) => allow_int_to_float, + (PhpType::Union(expected_members), PhpType::Union(actual_members)) => actual_members + .iter() + .all(|actual_member| { + expected_members.iter().any(|expected_member| { + strict_type_accepts(checker, expected_member, actual_member, allow_int_to_float) + }) + }), + (PhpType::Union(expected_members), _) => expected_members.iter().any(|expected_member| { + strict_type_accepts(checker, expected_member, actual, allow_int_to_float) + }), + (_, PhpType::Union(actual_members)) => actual_members.iter().all(|actual_member| { + strict_type_accepts(checker, expected, actual_member, allow_int_to_float) + }), + (PhpType::Object(_), PhpType::Object(_)) + | (PhpType::Iterable, PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Iterable) + | (PhpType::Iterable, PhpType::Object(_)) + | (PhpType::Array(_), PhpType::Array(_) | PhpType::AssocArray { .. }) + | (PhpType::AssocArray { .. }, PhpType::Array(_) | PhpType::AssocArray { .. }) => { + checker.type_accepts(expected, actual) + } + _ => false, + } +} + +/// Validates directly declared trait constants that do not depend on a relative class type. +fn validate_trait_constants( + checker: &mut Checker, + program: &Program, + errors: &mut Vec, +) { + for statement in program { + match &statement.kind { + StmtKind::TraitDecl { + name, constants, .. + } => { + for constant in constants { + if constant + .type_expr + .as_ref() + .is_some_and(type_expr_contains_relative_class) + { + continue; + } + validate_constant_declaration(checker, name, constant, errors); + } + } + StmtKind::NamespaceBlock { body, .. } => { + validate_trait_constants(checker, body, errors); + } + _ => {} + } + } +} + +/// Returns whether a type expression contains `self`, `static`, or `parent`. +fn type_expr_contains_relative_class(type_expr: &TypeExpr) -> bool { + match type_expr { + TypeExpr::Named(name) => matches!( + name.as_str().to_ascii_lowercase().as_str(), + "self" | "static" | "parent" + ), + TypeExpr::Nullable(inner) | TypeExpr::Array(inner) | TypeExpr::Buffer(inner) => { + type_expr_contains_relative_class(inner) + } + TypeExpr::Union(members) | TypeExpr::Intersection(members) => { + members.iter().any(type_expr_contains_relative_class) + } + _ => false, + } +} diff --git a/src/types/checker/schema/classes/state.rs b/src/types/checker/schema/classes/state.rs index f5b2e35f50..81df27388d 100644 --- a/src/types/checker/schema/classes/state.rs +++ b/src/types/checker/schema/classes/state.rs @@ -142,6 +142,16 @@ impl ClassBuildState { )) }) .collect::, CompileError>>()?, + constant_types: class + .constants + .iter() + .filter_map(|constant| { + constant + .type_expr + .clone() + .map(|type_expr| (constant.name.clone(), type_expr)) + }) + .collect(), constant_visibilities: class .constants .iter() diff --git a/src/types/checker/schema/enums.rs b/src/types/checker/schema/enums.rs index 6784f995f2..914f561269 100644 --- a/src/types/checker/schema/enums.rs +++ b/src/types/checker/schema/enums.rs @@ -404,12 +404,19 @@ pub(crate) fn insert_enum_metadata( // User-declared enum constants. Values are kept as their parsed expressions, matching the // class-constant representation. let mut constants = HashMap::new(); + let mut constant_types = HashMap::new(); let mut constant_visibilities = HashMap::new(); let mut final_constants = HashSet::new(); let mut constant_attribute_names = HashMap::new(); let mut constant_attribute_args = HashMap::new(); for constant in user_constants { constants.insert(constant.name.clone(), constant.value.clone()); + if let Some(type_expr) = &constant.type_expr { + constant_types.insert( + constant.name.clone(), + type_expr.substitute_relative_class_types(name, None), + ); + } constant_visibilities.insert(constant.name.clone(), constant.visibility.clone()); if constant.is_final { final_constants.insert(constant.name.clone()); @@ -444,6 +451,7 @@ pub(crate) fn insert_enum_metadata( is_readonly_class: true, allow_dynamic_properties: false, constants, + constant_types, constant_visibilities, final_constants, attribute_names: Vec::new(), diff --git a/src/types/checker/schema/interfaces.rs b/src/types/checker/schema/interfaces.rs index 292646385b..411cdd70e0 100644 --- a/src/types/checker/schema/interfaces.rs +++ b/src/types/checker/schema/interfaces.rs @@ -318,6 +318,7 @@ pub(crate) fn build_interface_info_recursive( } let mut iface_constants: HashMap = HashMap::new(); + let mut constant_types = HashMap::new(); let mut constant_declaring_interfaces = HashMap::new(); let mut final_constants = HashSet::new(); for parent_name in &interface.extends { @@ -325,6 +326,9 @@ pub(crate) fn build_interface_info_recursive( for (k, v) in &parent_info.constants { if !iface_constants.contains_key(k) { iface_constants.insert(k.clone(), v.clone()); + if let Some(type_expr) = parent_info.constant_types.get(k) { + constant_types.insert(k.clone(), type_expr.clone()); + } constant_declaring_interfaces.insert( k.clone(), parent_info @@ -354,6 +358,11 @@ pub(crate) fn build_interface_info_recursive( } } iface_constants.insert(c.name.clone(), c.value.clone()); + if let Some(type_expr) = &c.type_expr { + constant_types.insert(c.name.clone(), type_expr.clone()); + } else { + constant_types.remove(&c.name); + } constant_declaring_interfaces.insert(c.name.clone(), interface.name.clone()); if c.is_final { final_constants.insert(c.name.clone()); @@ -377,6 +386,7 @@ pub(crate) fn build_interface_info_recursive( static_method_declaring_interfaces, static_method_order, constants: iface_constants, + constant_types, constant_declaring_interfaces, final_constants, }, diff --git a/src/types/checker/schema/mod.rs b/src/types/checker/schema/mod.rs index f30a635970..47c66d69ae 100644 --- a/src/types/checker/schema/mod.rs +++ b/src/types/checker/schema/mod.rs @@ -10,12 +10,14 @@ pub(crate) mod validation; mod attribute_refs; +mod class_constants; mod defaults; mod interfaces; mod classes; mod enums; pub(crate) use attribute_refs::drop_unresolvable_attribute_arg_refs; +pub(crate) use class_constants::validate_deferred_class_constants; pub(crate) use defaults::validate_deferred_object_defaults; pub(crate) use interfaces::*; pub(crate) use classes::*; diff --git a/src/types/schema.rs b/src/types/schema.rs index eb12de78d2..c0689908a0 100644 --- a/src/types/schema.rs +++ b/src/types/schema.rs @@ -11,7 +11,9 @@ use std::collections::{HashMap, HashSet}; -use crate::parser::ast::{AttributeGroup, ClassMethod, Expr, ExprKind, StaticReceiver, Visibility}; +use crate::parser::ast::{ + AttributeGroup, ClassMethod, Expr, ExprKind, StaticReceiver, TypeExpr, Visibility, +}; use crate::span::Span; use super::{FunctionSig, PhpType}; @@ -231,6 +233,8 @@ pub struct InterfaceInfo { pub static_method_order: Vec, /// Interface constants (PHP 5.0+). Inherited from parent interfaces. pub constants: HashMap, + /// PHP 8.3 declared types for visible interface constants. + pub constant_types: HashMap, /// Declaring interface for each visible constant, keyed by case-sensitive constant name. pub constant_declaring_interfaces: HashMap, /// Interface constants declared with PHP 8.1+ `final`, including inherited parents. @@ -256,6 +260,8 @@ pub struct ClassInfo { /// User-declared class constants (PHP 7.1+). Maps the constant name to /// its value expression — codegen inlines the literal at access time. pub constants: HashMap, + /// PHP 8.3 declared types for constants declared directly on this class-like symbol. + pub constant_types: HashMap, /// Class constant visibilities keyed by case-sensitive constant name. pub constant_visibilities: HashMap, /// Class constants declared with PHP 8.1+ `final`, keyed by constant name. diff --git a/tests/codegen/oop/attributes.rs b/tests/codegen/oop/attributes.rs index 4dc363663c..4b80b40ae7 100644 --- a/tests/codegen/oop/attributes.rs +++ b/tests/codegen/oop/attributes.rs @@ -3856,6 +3856,40 @@ echo $backedAttrs[0]->newInstance()->label(); ); } +/// Verifies typed class constants expose declared named/union metadata through +/// direct and `ReflectionClass::getReflectionConstants()` construction paths. +#[test] +fn test_reflection_class_constant_declared_types() { + let out = compile_and_run( + r#"hasType() ? "typed" : "untyped") . ":"; +echo $count->getType()->getName() . ":"; +echo ($count->getType()->isBuiltin() ? "builtin" : "class") . "\n"; +$id = new ReflectionClassConstant(TypedReflectConstants::class, "ID"); +echo get_class($id->getType()) . ":" . (string) $id->getType() . "\n"; +$untyped = new ReflectionClassConstant(TypedReflectConstants::class, "UNTYPED"); +echo ($untyped->hasType() ? "typed" : "untyped") . ":"; +echo ($untyped->getType() === null ? "null" : "value") . "\n"; +foreach ((new ReflectionClass(TypedReflectConstants::class))->getReflectionConstants() as $constant) { + if ($constant->getName() === "COUNT") { + echo ($constant->hasType() ? "listed" : "missing") . ":"; + echo $constant->getType()->getName(); + } +} +"#, + ); + assert_eq!( + out, + "typed:int:builtin\nReflectionUnionType:int|string\nuntyped:null\nlisted:int" + ); +} + /// Verifies `ReflectionEnum` exposes AOT enum name and backing metadata. #[test] fn test_reflection_enum_owner_metadata() { diff --git a/tests/codegen/oop/constants.rs b/tests/codegen/oop/constants.rs index f02c70b693..d3ea775382 100644 --- a/tests/codegen/oop/constants.rs +++ b/tests/codegen/oop/constants.rs @@ -301,3 +301,18 @@ fn test_typed_class_constants_parse_and_read() { ); assert_eq!(out, "n:3"); } + +/// Verifies typed constant overrides may narrow a parent union and retain the +/// declared type when the constant is used in another typed expression. +#[test] +fn test_typed_class_constant_covariant_override() { + let out = compile_and_run( + r#" Date: Tue, 14 Jul 2026 18:31:03 +0200 Subject: [PATCH 4/5] docs(classes): document typed class constants --- docs/php/classes.md | 14 ++++++++------ examples/classes/main.php | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/php/classes.md b/docs/php/classes.md index 4f12786a7c..4272c2b19c 100644 --- a/docs/php/classes.md +++ b/docs/php/classes.md @@ -1296,8 +1296,8 @@ echo ($instance instanceof Route) ? "yes" : "no"; | `ReflectionClassConstant::isPrivate()` | Same as `ReflectionClassConstant::getName()` | Return whether the reflected class constant is private; enum cases report `false` | | `ReflectionClassConstant::isFinal()` | Same as `ReflectionClassConstant::getName()` | Return whether the reflected class/interface/trait/enum constant is final; enum cases report `false` | | `ReflectionClassConstant::isDeprecated()` | Same as `ReflectionClassConstant::getName()` | Return PHP's current non-deprecated default for retained class-constant metadata | -| `ReflectionClassConstant::hasType()` | Same as `ReflectionClassConstant::getName()` | Return `false` because elephc's current class-constant metadata is untyped | -| `ReflectionClassConstant::getType()` | Same as `ReflectionClassConstant::getName()` | Return `null` for the current untyped class-constant metadata | +| `ReflectionClassConstant::hasType()` | Same as `ReflectionClassConstant::getName()` | Return whether the reflected class constant has a declared PHP 8.3 type | +| `ReflectionClassConstant::getType()` | Same as `ReflectionClassConstant::getName()` | Return a retained `ReflectionNamedType`, `ReflectionUnionType`, or `ReflectionIntersectionType`, or `null` for an untyped constant | | `ReflectionClassConstant::getModifiers()` | Same as `ReflectionClassConstant::getName()` | Return PHP's `ReflectionClassConstant::IS_*` visibility/finality bitmask | | `ReflectionEnumUnitCase::getName()` / `ReflectionEnumBackedCase::getName()` | `new ReflectionEnumUnitCase($enum_name, $case_name)` or `new ReflectionEnumBackedCase($enum_name, $case_name)` | Return the reflected enum-case name | | `ReflectionEnumUnitCase::getValue()` / `ReflectionEnumBackedCase::getValue()` | Same as enum-case `getName()` | Return the reflected enum-case object | @@ -1380,7 +1380,7 @@ Limitations today: - A symbolic reference that elephc cannot resolve - for example a built-in class constant such as `Attribute::TARGET_CLASS`, which is not registered - is treated as unsupported metadata: the attribute still parses and compiles and `class_attribute_names()` still lists it, but its arguments are not reflectable through `getAttributes()`/`class_get_attributes()`/`class_attribute_args()`. - The flat `class_attribute_args()` helper materializes supported literal and array values directly, but symbolic references are resolved through `ReflectionClass::getAttributes()->getArguments()` / `ReflectionAttribute::newInstance()` instead. - When several attributes share a name on the same class, `class_attribute_args()` returns the args of the first match; `class_get_attributes()` does expose every occurrence as a separate `ReflectionAttribute` in source order. -- `ReflectionClass` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `getAttributes()`, `isFinal()`, `isAbstract()`, `isInterface()`, `isTrait()`, `isEnum()`, `isReadOnly()`, `isAnonymous()`, `isInstantiable()`, `isCloneable()`, `isIterable()`, `isIterateable()`, `isInternal()`, `isUserDefined()`, `getModifiers()`, `hasMethod()`, `hasProperty()`, `hasConstant()`, `getConstant()`, `getConstants()`, `getDefaultProperties()`, `getReflectionConstant()`, `getReflectionConstants()`, `implementsInterface()`, `isSubclassOf()`, `isInstance()`, `getInterfaceNames()`, `getInterfaces()`, `getTraitNames()`, `getTraits()`, `getTraitAliases()`, `getMethods()`, `getConstructor()`, `getParentClass()`, `getProperties()`, `newInstance()`, `newInstanceArgs()`, and `newInstanceWithoutConstructor()`. Its constructor accepts class-name strings and object arguments; object arguments reflect the runtime class of eval-created or generated/AOT objects. It also exposes PHP-compatible `ReflectionClass::IS_IMPLICIT_ABSTRACT`, `ReflectionClass::IS_FINAL`, `ReflectionClass::IS_EXPLICIT_ABSTRACT`, and `ReflectionClass::IS_READONLY` constants. `ReflectionClass::implementsInterface()` returns the metadata predicate for known interface names and throws PHP-compatible `ReflectionException` messages when the argument is missing or names a non-interface class-like symbol. `ReflectionClass::isSubclassOf()` returns the metadata predicate for known class/interface targets, excludes the reflected symbol itself, returns `false` for trait and enum targets, and throws PHP-compatible `ReflectionException` messages for missing names. `ReflectionClass::isInstance()` checks the provided object against the reflected class/interface/enum metadata and returns `false` for trait targets. `ReflectionClass::getDefaultProperties()` returns supported materialized property defaults, including static and instance properties, omits typed properties without an initializer, and reports implicit `null` for untyped concrete properties without an explicit initializer. `ReflectionClass::newInstance()` constructs eval-declared and bridge-supported generated/AOT reflected classes with public constructors and forwards positional, named, unpacked, and defaulted constructor arguments through eval's call binding. `ReflectionClass::newInstanceArgs()` constructs those reflected classes from indexed or string-keyed argument arrays, including arrays built at eval runtime; string keys become named constructor arguments. When the receiver is an inline `new ReflectionClass(Known::class)` expression, named arguments, static string-keyed unpacking, and constructor defaults are normalized through the reflected constructor signature. `ReflectionClass::newInstanceWithoutConstructor()` allocates concrete non-enum reflected classes with supported property defaults while skipping `__construct()`; specialized internal classes with runtime-owned storage still require dedicated support. Dynamic unpacking and named-argument forwarding from a `ReflectionClass` object held only in runtime storage still require richer runtime constructor binding. `ReflectionFunction` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `isInternal()`, `isUserDefined()`, `isClosure()`, `isDeprecated()`, `returnsReference()`, `isGenerator()`, `getAttributes()`, `getParameters()`, `getNumberOfParameters()`, `getNumberOfRequiredParameters()`, `hasReturnType()`, `getReturnType()`, `isVariadic()`, `hasTentativeReturnType()`, `getTentativeReturnType()`, `getClosureUsedVariables()`, `invoke()`, `invokeArgs()`, and `isDisabled()` for statically known user functions and supported first-class-callable builtins. AOT invocation supports reflected user functions with declared or inferred/untyped parameter contracts, plus supported callable builtins. Inside eval fragments, registered generated/AOT free-function defaults are reflected through `ReflectionParameter` metadata. `ReflectionMethod` construction accepts class-name strings and object arguments; object arguments resolve to the runtime class before method lookup. `ReflectionMethod` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `isInternal()`, `isUserDefined()`, `isClosure()`, `isDeprecated()`, `returnsReference()`, `isGenerator()`, `hasPrototype()`, `getPrototype()`, `getDeclaringClass()`, `getAttributes()`, `getParameters()`, `getNumberOfParameters()`, `getNumberOfRequiredParameters()`, `hasReturnType()`, `getReturnType()`, `isVariadic()`, `hasTentativeReturnType()`, `getTentativeReturnType()`, `isStatic()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isAbstract()`, `isConstructor()`, `isDestructor()`, and `getModifiers()`. It also exposes PHP-compatible `ReflectionMethod::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, `IS_STATIC`, `IS_FINAL`, and `IS_ABSTRACT` constants. `ReflectionParameter` supports direct construction for statically known user function names or supported callable-builtin names (`"function"`), class/interface/trait method arrays (`[ClassLike::class, "method"]`), and statically typed object-expression method arrays (`[$object_expr, "method"]`) outside eval; those object expressions are still evaluated before reflection metadata is materialized. Inside eval fragments it can also resolve object-method arrays from evaluated runtime objects, including inline `new` expressions. It also supports `getName()`, `getPosition()`, `isOptional()`, `isVariadic()`, `isPassedByReference()`, `canBePassedByValue()`, `isPromoted()`, `hasType()`, `getType()` for simple named, union, and intersection parameter types, `allowsNull()`, `isArray()`, `isCallable()`, `getAttributes()` for function and method parameter attributes, `getDeclaringClass()`, `getDeclaringFunction()`, `isDefaultValueAvailable()`, `getDefaultValue()`, `isDefaultValueConstant()`, and `getDefaultValueConstantName()` for supported scalar/null/class-constant/default-array metadata and object defaults with up to eight supported scalar/null constructor arguments from literals or constants, including omitted constructor defaults. `ReflectionNamedType` supports `getName()`, `allowsNull()`, and `isBuiltin()`. `ReflectionUnionType` and `ReflectionIntersectionType` support `getTypes()` and `allowsNull()`. `ReflectionProperty` supports `getName()`, `getDeclaringClass()`, `getAttributes()`, `isStatic()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isAbstract()`, `isReadOnly()`, `isPromoted()`, `isProtectedSet()`, `isPrivateSet()`, `getModifiers()`, `isDefault()`, `isDynamic()`, `isLazy()`, `skipLazyInitialization()`, `__toString()`, `hasHooks()`, `getHooks()`, `hasHook()`, `getHook()`, `hasType()`, `getType()`, `getSettableType()`, `hasDefaultValue()`, `getDefaultValue()` for supported property defaults, and `isInitialized()` for supported known property slots. It also exposes PHP-compatible `ReflectionProperty::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, `IS_STATIC`, `IS_FINAL`, `IS_ABSTRACT`, `IS_READONLY`, `IS_VIRTUAL`, `IS_PROTECTED_SET`, and `IS_PRIVATE_SET` constants. `ReflectionClassConstant` supports `getName()`, `getValue()`, `getAttributes()`, `getDeclaringClass()`, `isEnumCase()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isDeprecated()`, `hasType()`, `getType()`, and `getModifiers()`. It also exposes PHP-compatible `ReflectionClassConstant::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, and `IS_FINAL` constants. `ReflectionEnumUnitCase` and `ReflectionEnumBackedCase` support `getName()`, `getValue()`, `getAttributes()`, and `getDeclaringClass()`, and `ReflectionEnumBackedCase` also supports `getBackingValue()`. The enum-case reflectors expose the same `isDeprecated()`, `hasType()`, and `getType()` defaults as `ReflectionClassConstant`. Broader APIs such as object parameter defaults beyond that limited literal constructor-argument slice, broader internal-function metadata beyond supported first-class-callable builtin signatures, and runtime-only/dynamically typed object-expression `new ReflectionParameter(...)` construction outside eval fragments are not yet available. `ReflectionClassConstant::getValue()` returns the reflected class-constant value, or the enum-case object when the reflected constant is an enum case. An explicit `mixed` hint is reported as untyped. +- `ReflectionClass` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `getAttributes()`, `isFinal()`, `isAbstract()`, `isInterface()`, `isTrait()`, `isEnum()`, `isReadOnly()`, `isAnonymous()`, `isInstantiable()`, `isCloneable()`, `isIterable()`, `isIterateable()`, `isInternal()`, `isUserDefined()`, `getModifiers()`, `hasMethod()`, `hasProperty()`, `hasConstant()`, `getConstant()`, `getConstants()`, `getDefaultProperties()`, `getReflectionConstant()`, `getReflectionConstants()`, `implementsInterface()`, `isSubclassOf()`, `isInstance()`, `getInterfaceNames()`, `getInterfaces()`, `getTraitNames()`, `getTraits()`, `getTraitAliases()`, `getMethods()`, `getConstructor()`, `getParentClass()`, `getProperties()`, `newInstance()`, `newInstanceArgs()`, and `newInstanceWithoutConstructor()`. Its constructor accepts class-name strings and object arguments; object arguments reflect the runtime class of eval-created or generated/AOT objects. It also exposes PHP-compatible `ReflectionClass::IS_IMPLICIT_ABSTRACT`, `ReflectionClass::IS_FINAL`, `ReflectionClass::IS_EXPLICIT_ABSTRACT`, and `ReflectionClass::IS_READONLY` constants. `ReflectionClass::implementsInterface()` returns the metadata predicate for known interface names and throws PHP-compatible `ReflectionException` messages when the argument is missing or names a non-interface class-like symbol. `ReflectionClass::isSubclassOf()` returns the metadata predicate for known class/interface targets, excludes the reflected symbol itself, returns `false` for trait and enum targets, and throws PHP-compatible `ReflectionException` messages for missing names. `ReflectionClass::isInstance()` checks the provided object against the reflected class/interface/enum metadata and returns `false` for trait targets. `ReflectionClass::getDefaultProperties()` returns supported materialized property defaults, including static and instance properties, omits typed properties without an initializer, and reports implicit `null` for untyped concrete properties without an explicit initializer. `ReflectionClass::newInstance()` constructs eval-declared and bridge-supported generated/AOT reflected classes with public constructors and forwards positional, named, unpacked, and defaulted constructor arguments through eval's call binding. `ReflectionClass::newInstanceArgs()` constructs those reflected classes from indexed or string-keyed argument arrays, including arrays built at eval runtime; string keys become named constructor arguments. When the receiver is an inline `new ReflectionClass(Known::class)` expression, named arguments, static string-keyed unpacking, and constructor defaults are normalized through the reflected constructor signature. `ReflectionClass::newInstanceWithoutConstructor()` allocates concrete non-enum reflected classes with supported property defaults while skipping `__construct()`; specialized internal classes with runtime-owned storage still require dedicated support. Dynamic unpacking and named-argument forwarding from a `ReflectionClass` object held only in runtime storage still require richer runtime constructor binding. `ReflectionFunction` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `isInternal()`, `isUserDefined()`, `isClosure()`, `isDeprecated()`, `returnsReference()`, `isGenerator()`, `getAttributes()`, `getParameters()`, `getNumberOfParameters()`, `getNumberOfRequiredParameters()`, `hasReturnType()`, `getReturnType()`, `isVariadic()`, `hasTentativeReturnType()`, `getTentativeReturnType()`, `getClosureUsedVariables()`, `invoke()`, `invokeArgs()`, and `isDisabled()` for statically known user functions and supported first-class-callable builtins. AOT invocation supports reflected user functions with declared or inferred/untyped parameter contracts, plus supported callable builtins. Inside eval fragments, registered generated/AOT free-function defaults are reflected through `ReflectionParameter` metadata. `ReflectionMethod` construction accepts class-name strings and object arguments; object arguments resolve to the runtime class before method lookup. `ReflectionMethod` supports `getName()`, `getShortName()`, `getNamespaceName()`, `inNamespace()`, `isInternal()`, `isUserDefined()`, `isClosure()`, `isDeprecated()`, `returnsReference()`, `isGenerator()`, `hasPrototype()`, `getPrototype()`, `getDeclaringClass()`, `getAttributes()`, `getParameters()`, `getNumberOfParameters()`, `getNumberOfRequiredParameters()`, `hasReturnType()`, `getReturnType()`, `isVariadic()`, `hasTentativeReturnType()`, `getTentativeReturnType()`, `isStatic()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isAbstract()`, `isConstructor()`, `isDestructor()`, and `getModifiers()`. It also exposes PHP-compatible `ReflectionMethod::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, `IS_STATIC`, `IS_FINAL`, and `IS_ABSTRACT` constants. `ReflectionParameter` supports direct construction for statically known user function names or supported callable-builtin names (`"function"`), class/interface/trait method arrays (`[ClassLike::class, "method"]`), and statically typed object-expression method arrays (`[$object_expr, "method"]`) outside eval; those object expressions are still evaluated before reflection metadata is materialized. Inside eval fragments it can also resolve object-method arrays from evaluated runtime objects, including inline `new` expressions. It also supports `getName()`, `getPosition()`, `isOptional()`, `isVariadic()`, `isPassedByReference()`, `canBePassedByValue()`, `isPromoted()`, `hasType()`, `getType()` for simple named, union, and intersection parameter types, `allowsNull()`, `isArray()`, `isCallable()`, `getAttributes()` for function and method parameter attributes, `getDeclaringClass()`, `getDeclaringFunction()`, `isDefaultValueAvailable()`, `getDefaultValue()`, `isDefaultValueConstant()`, and `getDefaultValueConstantName()` for supported scalar/null/class-constant/default-array metadata and object defaults with up to eight supported scalar/null constructor arguments from literals or constants, including omitted constructor defaults. `ReflectionNamedType` supports `getName()`, `allowsNull()`, and `isBuiltin()`. `ReflectionUnionType` and `ReflectionIntersectionType` support `getTypes()` and `allowsNull()`. `ReflectionProperty` supports `getName()`, `getDeclaringClass()`, `getAttributes()`, `isStatic()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isAbstract()`, `isReadOnly()`, `isPromoted()`, `isProtectedSet()`, `isPrivateSet()`, `getModifiers()`, `isDefault()`, `isDynamic()`, `isLazy()`, `skipLazyInitialization()`, `__toString()`, `hasHooks()`, `getHooks()`, `hasHook()`, `getHook()`, `hasType()`, `getType()`, `getSettableType()`, `hasDefaultValue()`, `getDefaultValue()` for supported property defaults, and `isInitialized()` for supported known property slots. It also exposes PHP-compatible `ReflectionProperty::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, `IS_STATIC`, `IS_FINAL`, `IS_ABSTRACT`, `IS_READONLY`, `IS_VIRTUAL`, `IS_PROTECTED_SET`, and `IS_PRIVATE_SET` constants. `ReflectionClassConstant` supports `getName()`, `getValue()`, `getAttributes()`, `getDeclaringClass()`, `isEnumCase()`, `isPublic()`, `isProtected()`, `isPrivate()`, `isFinal()`, `isDeprecated()`, `hasType()`, `getType()` for retained named, nullable, union, and intersection declarations, and `getModifiers()`. It also exposes PHP-compatible `ReflectionClassConstant::IS_PUBLIC`, `IS_PROTECTED`, `IS_PRIVATE`, and `IS_FINAL` constants. `ReflectionEnumUnitCase` and `ReflectionEnumBackedCase` support `getName()`, `getValue()`, `getAttributes()`, and `getDeclaringClass()`, and `ReflectionEnumBackedCase` also supports `getBackingValue()`. The enum-case reflectors expose the untyped `hasType()` / `getType()` defaults. Broader APIs such as object parameter defaults beyond that limited literal constructor-argument slice, broader internal-function metadata beyond supported first-class-callable builtin signatures, and runtime-only/dynamically typed object-expression `new ReflectionParameter(...)` construction outside eval fragments are not yet available. `ReflectionClassConstant::getValue()` returns the reflected class-constant value, or the enum-case object when the reflected constant is an enum case. An explicit `mixed` class-constant hint is retained as a declared type. - `ReflectionObject` supports the inherited class-metadata methods for object arguments and materializes the same metadata through a `ReflectionObject` instance. - `ReflectionEnum` additionally supports backing metadata (`isBacked()`, `getBackingType()`); inside eval fragments it also supports enum-case lookup (`hasCase()`, `getCase()`, `getCases()`). Enum-case reflectors expose `getEnum()`. - `ReflectionNamedType`, `ReflectionUnionType`, and `ReflectionIntersectionType` also support `__toString()` for retained named, nullable, union, and intersection metadata. @@ -1419,8 +1419,8 @@ declared properties. ```php Date: Tue, 14 Jul 2026 18:47:12 +0200 Subject: [PATCH 5/5] docs(changelog): document typed class constants --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0e6fda74..f9e8b4e4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Releases are listed newest first. ## [Unreleased] - Added experimental PHP `eval()` support across macOS ARM64, Linux ARM64, and Linux x86_64. Eligible literal fragments are parsed at compile time and lowered to native EIR, including direct or scope-backed caller-local synchronization; dynamic strings and unsupported literal shapes fall back to the optional statically linked `elephc-magician` EvalIR interpreter. Within the supported eval subset, the fallback preserves caller/global scope updates, dynamic functions/classes/constants, callables, reflection, builtins, exceptions, ownership/COW behavior, and PHP-visible diagnostics without requiring PHP or the Zend Engine. Bridge linking is automatic when required and can be forced with `--with-eval`; generated builtin documentation now reports AOT and eval availability separately. +- Added PHP 8.3 typed class constants for classes, interfaces, traits, and enums. Declared types are enforced for initializers and inherited overrides, including covariant narrowing, and are exposed through `ReflectionClassConstant::hasType()` and `getType()`; untyped constants and enum cases retain PHP-compatible reflection defaults. - Fixed `--web --max-requests` crash-loop accounting (issue #516): workers that exit after a planned request-quota recycle now use a dedicated status that the master excludes from startup-death streaks, so sustained traffic with a low quota keeps respawning workers instead of shutting down the server; genuine setup failures, other exit codes, and signal deaths still feed the guard. - Fixed nullable chained array reads (issue #525): consuming a value from a `?array` receiver now releases the one-shot hidden owned Mixed temporary created by nullable access, on both null and non-null paths, without invalidating the extracted result or double-releasing repeated reads. - Fixed owned boxed Mixed temporaries leaking after string conversion (issue #527): explicit `(string)` casts plus implicit concatenation and interpolation now release detached Mixed sources, including by-value `foreach` element reads and non-null unions represented as Mixed; borrowed, persistent, moved, and non-heap values remain release no-ops.