From c2365857eec46ce62c9f21444e41191113844099 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Tue, 7 Jul 2026 15:38:31 +0200 Subject: [PATCH 1/5] fix: dispatch gettype on codegen repr so nullable-int unions use the tagged-scalar path --- src/codegen/lower_inst/builtins.rs | 5 ++++- tests/codegen/control_flow/ternary.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/codegen/lower_inst/builtins.rs b/src/codegen/lower_inst/builtins.rs index 43b62538b6..b138b85d1b 100644 --- a/src/codegen/lower_inst/builtins.rs +++ b/src/codegen/lower_inst/builtins.rs @@ -130,7 +130,10 @@ pub(crate) fn lower_gettype(ctx: &mut FunctionContext<'_>, inst: &Instruction) - ensure_arg_count(inst, "gettype", 1)?; let value = expect_operand(inst, 0)?; let ty = ctx.raw_value_php_type(value)?; - if matches!(ty, PhpType::TaggedScalar) { + // Dispatch on the codegen representation: a nullable-int union stores an + // inline tagged scalar, not a boxed Mixed cell, so unboxing it would read + // a non-pointer payload as a heap cell and crash. + if matches!(ty.codegen_repr(), PhpType::TaggedScalar) { emit_tagged_scalar_gettype(ctx, value)?; return store_if_result(ctx, inst); } diff --git a/tests/codegen/control_flow/ternary.rs b/tests/codegen/control_flow/ternary.rs index 07b9e5d4c1..e2602d7f0d 100644 --- a/tests/codegen/control_flow/ternary.rs +++ b/tests/codegen/control_flow/ternary.rs @@ -30,6 +30,22 @@ fn test_ternary_int() { assert_eq!(out, "7"); } +/// Regression test for gettype() on a ternary-produced nullable int: the +/// merge temp is an inline tagged scalar (`null|int`), which the gettype() +/// emitter previously unboxed as a boxed Mixed cell and crashed. +#[test] +fn test_ternary_int_null_gettype() { + let out = compile_and_run( + r#" Date: Tue, 7 Jul 2026 15:38:31 +0200 Subject: [PATCH 2/5] fix: merge heterogeneous match arm types to mixed instead of coercing to one arm type --- docs/php/control-structures.md | 6 + src/ir_lower/expr/mod.rs | 32 +- src/types/checker/inference/expr/mod.rs | 34 ++- tests/codegen/control_flow.rs | 2 + .../codegen/control_flow/match_expressions.rs | 282 ++++++++++++++++++ 5 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 tests/codegen/control_flow/match_expressions.rs diff --git a/docs/php/control-structures.md b/docs/php/control-structures.md index 1c920b420f..72061fa066 100644 --- a/docs/php/control-structures.md +++ b/docs/php/control-structures.md @@ -205,6 +205,12 @@ echo $result; // two If no arm matches and there is no `default`, elephc aborts with a fatal runtime error. +Arms may produce values of different types (objects, arrays, strings, ints, `null`), +and an arm may be a `throw` expression. When the arm types are heterogeneous, the +result is stored as a boxed `mixed` value and each value-producing arm keeps its +own runtime type, matching PHP. Arms whose types share one runtime representation +(two array types, or `int` and `bool`) merge to that representation instead. + ## try / catch / finally / throw ```php diff --git a/src/ir_lower/expr/mod.rs b/src/ir_lower/expr/mod.rs index 4867521749..fd59d42d74 100644 --- a/src/ir_lower/expr/mod.rs +++ b/src/ir_lower/expr/mod.rs @@ -7124,7 +7124,7 @@ fn lower_match( expr: &Expr, ) -> LoweredValue { let subject = lower_expr(ctx, subject); - let result_type = fallback_expr_type(expr); + let result_type = match_merge_result_type(ctx, arms, default, expr); let temp_name = ctx.declare_owned_hidden_temp(result_type.clone()); let merge = ctx.builder.create_named_block("match.merge", Vec::new()); @@ -10213,6 +10213,33 @@ fn branch_merge_result_type( wider_type_for_merge(&fallback_ty, &branch_ty.codegen_repr()) } +/// Chooses a match hidden-temp type by merging every arm result type, so +/// heterogeneous arms (e.g. object/array/string) materialize a Mixed temp +/// boxed per arm instead of coercing all arms to one unified scalar type. +fn match_merge_result_type( + ctx: &LoweringContext<'_, '_>, + arms: &[(Vec, Expr)], + default: Option<&Expr>, + expr: &Expr, +) -> PhpType { + let mut merged: Option = None; + for result in arms.iter().map(|(_, result)| result).chain(default) { + let arm_ty = materialized_expr_type_for_merge(ctx, result); + merged = Some(match merged { + Some(acc) => nullable_aware_branch_merge_type(&acc, &arm_ty), + None => arm_ty, + }); + } + let Some(merged) = merged else { + return fallback_expr_type(expr); + }; + if php_type_allows_null(&merged) { + return merged; + } + let fallback_ty = fallback_expr_type(expr).codegen_repr(); + wider_type_for_merge(&fallback_ty, &merged.codegen_repr()) +} + /// Chooses a ternary branch merge type without erasing PHP null branches. fn nullable_aware_branch_merge_type(left: &PhpType, right: &PhpType) -> PhpType { if php_type_allows_null(left) || php_type_allows_null(right) { @@ -10256,6 +10283,9 @@ fn materialized_expr_type_for_merge(ctx: &LoweringContext<'_, '_>, expr: &Expr) else_expr, .. } => branch_merge_result_type(ctx, then_expr, else_expr, expr), + ExprKind::Match { arms, default, .. } => { + match_merge_result_type(ctx, arms, default.as_deref(), expr) + } ExprKind::ShortTernary { value, default } => { let value_ty = materialized_expr_type_for_merge(ctx, value).codegen_repr(); let default_ty = materialized_expr_type_for_merge(ctx, default).codegen_repr(); diff --git a/src/types/checker/inference/expr/mod.rs b/src/types/checker/inference/expr/mod.rs index 07b8c4ad5b..644d479fc8 100644 --- a/src/types/checker/inference/expr/mod.rs +++ b/src/types/checker/inference/expr/mod.rs @@ -130,21 +130,23 @@ impl Checker { default, } => { self.infer_type(subject, env)?; - let mut result_ty = None; + let mut result_ty: Option = None; for (conditions, result) in arms { for c in conditions { self.infer_type(c, env)?; } let ty = self.infer_type(result, env)?; - if result_ty.is_none() { - result_ty = Some(ty); - } + result_ty = Some(match result_ty { + Some(acc) => merge_match_arm_result_type(acc, ty), + None => ty, + }); } if let Some(d) = default { let ty = self.infer_type(d, env)?; - if result_ty.is_none() { - result_ty = Some(ty); - } + result_ty = Some(match result_ty { + Some(acc) => merge_match_arm_result_type(acc, ty), + None => ty, + }); } Ok(result_ty.unwrap_or(PhpType::Void)) } @@ -712,3 +714,21 @@ fn is_valid_string_offset_index(index: &Expr, idx_ty: &PhpType) -> bool { if crate::types::parse_php_string_offset_literal(value).is_some() ) } + +/// Merges two match arm result types: identical arms keep their type, +/// `Never`-typed arms (`throw`) and `Void`-typed arms (checker `null`) defer +/// to the other arm's type, and any other heterogeneous pair widens to +/// `Mixed` so each arm's runtime value survives instead of being coerced to +/// the first arm's type. +fn merge_match_arm_result_type(acc: PhpType, next: PhpType) -> PhpType { + if acc == next { + return acc; + } + if matches!(acc, PhpType::Void | PhpType::Never) { + return next; + } + if matches!(next, PhpType::Void | PhpType::Never) { + return acc; + } + PhpType::Mixed +} diff --git a/tests/codegen/control_flow.rs b/tests/codegen/control_flow.rs index 43c8cf8402..040cb8b6d1 100644 --- a/tests/codegen/control_flow.rs +++ b/tests/codegen/control_flow.rs @@ -21,5 +21,7 @@ mod assignments; mod nulls; #[path = "control_flow/ternary.rs"] mod ternary; +#[path = "control_flow/match_expressions.rs"] +mod match_expressions; #[path = "control_flow/closures.rs"] mod closures; diff --git a/tests/codegen/control_flow/match_expressions.rs b/tests/codegen/control_flow/match_expressions.rs new file mode 100644 index 0000000000..d8e98a8012 --- /dev/null +++ b/tests/codegen/control_flow/match_expressions.rs @@ -0,0 +1,282 @@ +//! Purpose: +//! Integration and regression tests for match expressions with heterogeneous +//! arm result types (object/array/string/int/null mixes) and homogeneous arms. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Regression coverage for issue #488: heterogeneous match arms must merge +//! to a Mixed hidden temp (boxed per arm) instead of coercing every arm to +//! one scalar-biased unified type, which fatally cast object arms to string. +//! - `gettype` probes assert PHP's per-arm type preservation across arms. + +use super::*; + +/// Regression test for issue #488: a match whose arms produce an object, an +/// array, and a string must preserve each arm's value instead of fatally +/// coercing the object arm to string. +#[test] +fn test_match_heterogeneous_object_array_string_arms() { + let out = compile_and_run( + r#" new stdClass(), + 1 => [1, 2], + default => "s", + }; +} +$count = 0; +for ($i = 0; $i < 40; $i++) { + $v = pick($i); + $count++; +} +echo $count; +"#, + ); + assert_eq!(out, "40"); +} + +/// Tests that object and string match arms each keep their own runtime type +/// (PHP prints "object|string"), in both arm orders. +#[test] +fn test_match_object_and_string_arms_preserve_types() { + let out = compile_and_run( + r#" new stdClass(), + default => "s", + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|string"); + let out = compile_and_run( + r#" "s", + default => new stdClass(), + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "string|object"); +} + +/// Tests that object and array match arms are not silently unified: PHP +/// reports "object|array" for the two reached arms. +#[test] +fn test_match_object_and_array_arms_preserve_types() { + let out = compile_and_run( + r#" new stdClass(), + default => [1, 2], + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|array"); +} + +/// Tests that an object arm mixed with an int arm compiles and preserves both +/// runtime types (previously failed with an unsupported object-to-int cast). +#[test] +fn test_match_object_and_int_arms_preserve_types() { + let out = compile_and_run( + r#" new stdClass(), + default => 7, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|integer"); +} + +/// Tests that array and int match arms keep their own types instead of the +/// int arm silently absorbing the array arm (or vice versa by arm order). +#[test] +fn test_match_array_and_int_arms_preserve_types() { + let out = compile_and_run( + r#" [1, 2], + default => 7, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "array|integer"); +} + +/// Tests that string and int match arms keep their own types instead of the +/// string arm absorbing the int arm into a unified string temp. +#[test] +fn test_match_string_and_int_arms_preserve_types() { + let out = compile_and_run( + r#" "s", + default => 7, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "string|integer"); +} + +/// Tests that object and null match arms preserve both results: PHP reports +/// "object|NULL" instead of coercing the null arm into an object. +#[test] +fn test_match_object_and_null_arms_preserve_types() { + let out = compile_and_run( + r#" new stdClass(), + default => null, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|NULL"); +} + +/// Tests a heterogeneous match consumed at the top level (no enclosing +/// function or declared return type): the fatal object-to-string cast +/// happened inside the match arm itself, before any return coercion. +#[test] +fn test_match_heterogeneous_arms_top_level() { + let out = compile_and_run( + r#" new stdClass(), + default => "s", +}; +echo gettype($v); +"#, + ); + assert_eq!(out, "object"); +} + +/// Tests a heterogeneous match returned from a function with no declared +/// return type: the checker's inferred match type must agree with the +/// lowered Mixed merge instead of erroring on an unsupported cast. +#[test] +fn test_match_heterogeneous_arms_inferred_return_type() { + let out = compile_and_run( + r#" new stdClass(), + default => "s", + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|string"); +} + +/// Tests that homogeneous string arms keep working (the merged temp stays a +/// plain string, no boxing regression for same-typed arms). +#[test] +fn test_match_homogeneous_string_arms() { + let out = compile_and_run( + r#" "zero", + 1 => "one", + default => "many", +}; +"#, + ); + assert_eq!(out, "one"); +} + +/// Tests that a throw-only default arm does not widen the merged match type: +/// the value arms stay strings and the non-throw path prints normally. +#[test] +fn test_match_throw_default_arm_keeps_value_arm_type() { + let out = compile_and_run( + r#" "zero", + 1 => "one", + default => throw new Exception("nope"), + }; +} +echo pick(0), "|", pick(1); +"#, + ); + assert_eq!(out, "zero|one"); +} + +/// Regression test for the gettype() dispatch on a match-produced nullable +/// int: the hidden temp is an inline tagged scalar (`null|int`), which the +/// gettype() emitter previously unboxed as a boxed Mixed cell and crashed. +#[test] +fn test_match_int_and_null_arms_gettype() { + let out = compile_and_run( + r#" 1, + default => null, +}; +echo gettype($v), "|"; +$w = match($argc) { + 99 => 1, + default => null, +}; +echo gettype($w); +"#, + ); + assert_eq!(out, "integer|NULL"); +} + +/// Tests that heterogeneous scalar match arms stay heap-balanced: the boxed +/// per-arm values and the hidden Mixed temp must release cleanly. +#[test] +fn test_match_heterogeneous_scalar_arms_heap_clean() { + let out = compile_and_run_with_heap_debug( + r#" "s", + default => 7, + }; +} +$count = 0; +for ($i = 0; $i < 20; $i++) { + $v = pick($i); + $count++; +} +echo $count; +"#, + ); + assert_eq!(out.stdout, "20"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected clean heap summary, got: {}", + out.stderr + ); +} From 57f6667bd0dce0b0e798cd3ff2c6dc70e9a5060b Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Wed, 8 Jul 2026 22:25:56 +0200 Subject: [PATCH 3/5] docs: regenerate builtins catalog --- docs/internals/builtins/array/count.md | 2 +- docs/internals/builtins/class/class_exists.md | 2 +- docs/internals/builtins/class/enum_exists.md | 2 +- .../builtins/class/function_exists.md | 2 +- .../builtins/class/interface_exists.md | 2 +- docs/internals/builtins/class/trait_exists.md | 2 +- docs/internals/builtins/misc/defined.md | 2 +- docs/internals/builtins/misc/empty.md | 2 +- docs/internals/builtins/misc/phpversion.md | 2 +- docs/internals/builtins/string/strlen.md | 2 +- docs/internals/builtins/type/boolval.md | 2 +- docs/internals/builtins/type/floatval.md | 2 +- docs/internals/builtins/type/intval.md | 2 +- docs/internals/builtins/type/is_array.md | 2 +- docs/internals/builtins/type/is_bool.md | 2 +- docs/internals/builtins/type/is_callable.md | 2 +- docs/internals/builtins/type/is_float.md | 2 +- docs/internals/builtins/type/is_int.md | 2 +- docs/internals/builtins/type/is_iterable.md | 2 +- docs/internals/builtins/type/is_null.md | 2 +- docs/internals/builtins/type/is_object.md | 2 +- docs/internals/builtins/type/is_scalar.md | 2 +- docs/internals/builtins/type/is_string.md | 2 +- scripts/docs/builtin_registry.json | 46 +++++++++---------- 24 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/internals/builtins/array/count.md b/docs/internals/builtins/array/count.md index b25bacadc1..74a4e5442f 100644 --- a/docs/internals/builtins/array/count.md +++ b/docs/internals/builtins/array/count.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/count.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/count.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:439](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L439) (`lower_count`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:442](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L442) (`lower_count`) - **Function symbol**: `lower_count()` diff --git a/docs/internals/builtins/class/class_exists.md b/docs/internals/builtins/class/class_exists.md index cc3671b2f7..20fde3f23f 100644 --- a/docs/internals/builtins/class/class_exists.md +++ b/docs/internals/builtins/class/class_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_exists.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:293](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L293) (`lower_class_like_exists`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:296](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L296) (`lower_class_like_exists`) - **Function symbol**: `lower_class_like_exists()` diff --git a/docs/internals/builtins/class/enum_exists.md b/docs/internals/builtins/class/enum_exists.md index 4d97bdcb0a..bc7c097301 100644 --- a/docs/internals/builtins/class/enum_exists.md +++ b/docs/internals/builtins/class/enum_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/enum_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/enum_exists.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:293](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L293) (`lower_class_like_exists`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:296](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L296) (`lower_class_like_exists`) - **Function symbol**: `lower_class_like_exists()` diff --git a/docs/internals/builtins/class/function_exists.md b/docs/internals/builtins/class/function_exists.md index e72e15dfbb..dee10064c8 100644 --- a/docs/internals/builtins/class/function_exists.md +++ b/docs/internals/builtins/class/function_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/function_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/function_exists.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:276](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L276) (`lower_function_exists`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:279](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L279) (`lower_function_exists`) - **Function symbol**: `lower_function_exists()` diff --git a/docs/internals/builtins/class/interface_exists.md b/docs/internals/builtins/class/interface_exists.md index 11a13350ab..86bade85d0 100644 --- a/docs/internals/builtins/class/interface_exists.md +++ b/docs/internals/builtins/class/interface_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/interface_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/interface_exists.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:293](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L293) (`lower_class_like_exists`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:296](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L296) (`lower_class_like_exists`) - **Function symbol**: `lower_class_like_exists()` diff --git a/docs/internals/builtins/class/trait_exists.md b/docs/internals/builtins/class/trait_exists.md index 93e606d30c..40ac0a02fe 100644 --- a/docs/internals/builtins/class/trait_exists.md +++ b/docs/internals/builtins/class/trait_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/trait_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/trait_exists.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:293](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L293) (`lower_class_like_exists`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:296](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L296) (`lower_class_like_exists`) - **Function symbol**: `lower_class_like_exists()` diff --git a/docs/internals/builtins/misc/defined.md b/docs/internals/builtins/misc/defined.md index ba5a89d8ad..502a191a96 100644 --- a/docs/internals/builtins/misc/defined.md +++ b/docs/internals/builtins/misc/defined.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/defined.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/defined.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:261](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L261) (`lower_defined`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:264](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L264) (`lower_defined`) - **Function symbol**: `lower_defined()` diff --git a/docs/internals/builtins/misc/empty.md b/docs/internals/builtins/misc/empty.md index 8d6543af38..865f61a3ee 100644 --- a/docs/internals/builtins/misc/empty.md +++ b/docs/internals/builtins/misc/empty.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/types/signatures.rs`](https://github.com/illegalstudio/elephc/blob/main/src/types/signatures.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:618](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L618) (`lower_empty`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:621](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L621) (`lower_empty`) - **Function symbol**: `lower_empty()` diff --git a/docs/internals/builtins/misc/phpversion.md b/docs/internals/builtins/misc/phpversion.md index 0d0b423615..dddb960274 100644 --- a/docs/internals/builtins/misc/phpversion.md +++ b/docs/internals/builtins/misc/phpversion.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/phpversion.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/phpversion.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:251](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L251) (`lower_phpversion`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:254](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L254) (`lower_phpversion`) - **Function symbol**: `lower_phpversion()` diff --git a/docs/internals/builtins/string/strlen.md b/docs/internals/builtins/string/strlen.md index 991c85188a..716aa9ca3e 100644 --- a/docs/internals/builtins/string/strlen.md +++ b/docs/internals/builtins/string/strlen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strlen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strlen.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:493](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L493) (`lower_strlen`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:496](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L496) (`lower_strlen`) - **Function symbol**: `lower_strlen()` diff --git a/docs/internals/builtins/type/boolval.md b/docs/internals/builtins/type/boolval.md index 9db1506fb8..ca4705b17b 100644 --- a/docs/internals/builtins/type/boolval.md +++ b/docs/internals/builtins/type/boolval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/boolval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/boolval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:586](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L586) (`lower_boolval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:589](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L589) (`lower_boolval`) - **Function symbol**: `lower_boolval()` diff --git a/docs/internals/builtins/type/floatval.md b/docs/internals/builtins/type/floatval.md index feda4bba04..572fda9155 100644 --- a/docs/internals/builtins/type/floatval.md +++ b/docs/internals/builtins/type/floatval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/floatval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/floatval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:556](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L556) (`lower_floatval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:559](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L559) (`lower_floatval`) - **Function symbol**: `lower_floatval()` diff --git a/docs/internals/builtins/type/intval.md b/docs/internals/builtins/type/intval.md index dbaef413b1..52da2322dc 100644 --- a/docs/internals/builtins/type/intval.md +++ b/docs/internals/builtins/type/intval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/intval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/intval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:523](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L523) (`lower_intval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:526](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L526) (`lower_intval`) - **Function symbol**: `lower_intval()` diff --git a/docs/internals/builtins/type/is_array.md b/docs/internals/builtins/type/is_array.md index aaf5a53e50..2f66d63239 100644 --- a/docs/internals/builtins/type/is_array.md +++ b/docs/internals/builtins/type/is_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_array.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1002](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1002) (`lower_is_array`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1005](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1005) (`lower_is_array`) - **Function symbol**: `lower_is_array()` diff --git a/docs/internals/builtins/type/is_bool.md b/docs/internals/builtins/type/is_bool.md index aa91c078e6..bd5c4a9125 100644 --- a/docs/internals/builtins/type/is_bool.md +++ b/docs/internals/builtins/type/is_bool.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_bool.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_bool.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:736](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L736) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:739](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L739) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_callable.md b/docs/internals/builtins/type/is_callable.md index 2a2d5cf3d7..45754870c7 100644 --- a/docs/internals/builtins/type/is_callable.md +++ b/docs/internals/builtins/type/is_callable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_callable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_callable.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:319](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L319) (`lower_is_callable`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:322](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L322) (`lower_is_callable`) - **Function symbol**: `lower_is_callable()` diff --git a/docs/internals/builtins/type/is_float.md b/docs/internals/builtins/type/is_float.md index 280ed86bab..9d69d02aa2 100644 --- a/docs/internals/builtins/type/is_float.md +++ b/docs/internals/builtins/type/is_float.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_float.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_float.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:736](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L736) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:739](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L739) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_int.md b/docs/internals/builtins/type/is_int.md index 951c5da4b3..45eaef5632 100644 --- a/docs/internals/builtins/type/is_int.md +++ b/docs/internals/builtins/type/is_int.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_int.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_int.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:736](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L736) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:739](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L739) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_iterable.md b/docs/internals/builtins/type/is_iterable.md index fdc8b3277b..9c8f08c835 100644 --- a/docs/internals/builtins/type/is_iterable.md +++ b/docs/internals/builtins/type/is_iterable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_iterable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_iterable.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:794](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L794) (`lower_is_iterable`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:797](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L797) (`lower_is_iterable`) - **Function symbol**: `lower_is_iterable()` diff --git a/docs/internals/builtins/type/is_null.md b/docs/internals/builtins/type/is_null.md index adbf86dfc0..51cc646186 100644 --- a/docs/internals/builtins/type/is_null.md +++ b/docs/internals/builtins/type/is_null.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_null.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:992](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L992) (`lower_is_null_builtin`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:995](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L995) (`lower_is_null_builtin`) - **Function symbol**: `lower_is_null_builtin()` diff --git a/docs/internals/builtins/type/is_object.md b/docs/internals/builtins/type/is_object.md index db8bf7064b..552788afd2 100644 --- a/docs/internals/builtins/type/is_object.md +++ b/docs/internals/builtins/type/is_object.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_object.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_object.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1017](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1017) (`lower_is_object`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1020](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1020) (`lower_is_object`) - **Function symbol**: `lower_is_object()` diff --git a/docs/internals/builtins/type/is_scalar.md b/docs/internals/builtins/type/is_scalar.md index c2717fc7aa..8ba7c0b7e4 100644 --- a/docs/internals/builtins/type/is_scalar.md +++ b/docs/internals/builtins/type/is_scalar.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_scalar.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_scalar.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1033](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1033) (`lower_is_scalar`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1036](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1036) (`lower_is_scalar`) - **Function symbol**: `lower_is_scalar()` diff --git a/docs/internals/builtins/type/is_string.md b/docs/internals/builtins/type/is_string.md index 64cd46c5de..4b47b3934b 100644 --- a/docs/internals/builtins/type/is_string.md +++ b/docs/internals/builtins/type/is_string.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_string.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:736](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L736) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:739](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L739) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/scripts/docs/builtin_registry.json b/scripts/docs/builtin_registry.json index 8f97ec8891..c7e458c03d 100644 --- a/scripts/docs/builtin_registry.json +++ b/scripts/docs/builtin_registry.json @@ -3332,7 +3332,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_boolval", - "codegen_line": 586, + "codegen_line": 589, "notes": [ "Lowers `boolval()` using the same concrete scalar PHP truthiness rules as `IsTruthy`." ], @@ -4104,7 +4104,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_class_like_exists", - "codegen_line": 293, + "codegen_line": 296, "notes": [ "Lowers AOT class/interface/enum existence checks for literal names." ], @@ -4528,7 +4528,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_count", - "codegen_line": 439, + "codegen_line": 442, "notes": [ "Lowers `count(array)` for concrete array values by reading the runtime length header.", "Called from `crate::builtins::array::count` (the registry home) via a thin wrapper.", @@ -4938,7 +4938,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_defined", - "codegen_line": 261, + "codegen_line": 264, "notes": [ "Lowers `defined(\"NAME\")` for compile-time string constant names." ], @@ -5172,7 +5172,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_empty", - "codegen_line": 618, + "codegen_line": 621, "notes": [ "Lowers `empty()` for concrete scalar and array-like operands." ], @@ -5211,7 +5211,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_class_like_exists", - "codegen_line": 293, + "codegen_line": 296, "notes": [ "Lowers AOT class/interface/enum existence checks for literal names." ], @@ -6295,7 +6295,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_floatval", - "codegen_line": 556, + "codegen_line": 559, "notes": [ "Lowers `floatval()` for concrete scalar operands." ], @@ -7094,7 +7094,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_function_exists", - "codegen_line": 276, + "codegen_line": 279, "notes": [ "Lowers `function_exists(\"name\")` for compile-time string names.", "Recognizes user functions, externs, catalog builtins, and the date/time procedural aliases", @@ -9234,7 +9234,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_class_like_exists", - "codegen_line": 293, + "codegen_line": 296, "notes": [ "Lowers AOT class/interface/enum existence checks for literal names." ], @@ -9278,7 +9278,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_intval", - "codegen_line": 523, + "codegen_line": 526, "notes": [ "Lowers `intval()` for concrete scalar operands." ], @@ -9409,7 +9409,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_array", - "codegen_line": 1002, + "codegen_line": 1005, "notes": [ "Lowers `is_array()`: true for statically-known arrays/hashes, or a boxed Mixed/Union value", "whose runtime tag is an indexed (4) or associative (5) array. An `iterable`-typed value is", @@ -9448,7 +9448,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 736, + "codegen_line": 739, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -9485,7 +9485,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_callable", - "codegen_line": 319, + "codegen_line": 322, "notes": [ "Lowers `is_callable(value)` through static lookup or runtime callable-shape helpers." ], @@ -9687,7 +9687,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 736, + "codegen_line": 739, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -9761,7 +9761,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 736, + "codegen_line": 739, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -9798,7 +9798,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_iterable", - "codegen_line": 794, + "codegen_line": 797, "notes": [ "Lowers `is_iterable()` for concrete values and boxed Mixed payloads." ], @@ -9914,7 +9914,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_null_builtin", - "codegen_line": 992, + "codegen_line": 995, "notes": [ "Lowers `is_null()` for concrete scalar values and boxed Mixed payloads." ], @@ -9988,7 +9988,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_object", - "codegen_line": 1017, + "codegen_line": 1020, "notes": [ "Lowers `is_object()`: true for statically-known objects, or a boxed Mixed/Union value whose", "runtime tag is an object (6)." @@ -10104,7 +10104,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_scalar", - "codegen_line": 1033, + "codegen_line": 1036, "notes": [ "Lowers `is_scalar()`: true for int/float/string/bool, a non-null tagged scalar, or a boxed", "Mixed/Union value whose runtime tag is int (0), string (1), float (2), or bool (3). Null,", @@ -10143,7 +10143,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 736, + "codegen_line": 739, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -12129,7 +12129,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_phpversion", - "codegen_line": 251, + "codegen_line": 254, "notes": [ "Lowers `phpversion()` as the compiler package version string." ], @@ -17521,7 +17521,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_strlen", - "codegen_line": 493, + "codegen_line": 496, "notes": [ "Lowers `strlen()` by coercing string-like values and returning the byte length." ], @@ -18355,7 +18355,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_class_like_exists", - "codegen_line": 293, + "codegen_line": 296, "notes": [ "Lowers AOT class/interface/enum existence checks for literal names." ], From a32c2b6405f1043a8c47bb515cda5b279b0a4832 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Wed, 8 Jul 2026 23:47:32 +0200 Subject: [PATCH 4/5] fix: match null arms keep the checker's inferred merge nullable The checker's arm merge typed both null literals and throw arms as Void and made them defer to the other arm's type, so a match returned through an inferred return type coerced the null arm's value (NULL|string became string|string). Throw arms are now normalized to Never at the merge call site and genuine null arms widen the merge to a nullable union, mirroring the lowered temp's nullable-aware merge. Also adds review coverage: heterogeneous arm values consumed (not just gettype-probed), float/int arms, and a nested heterogeneous match arm. --- src/types/checker/inference/expr/mod.rs | 53 ++++++-- .../codegen/control_flow/match_expressions.rs | 120 ++++++++++++++++++ 2 files changed, 165 insertions(+), 8 deletions(-) diff --git a/src/types/checker/inference/expr/mod.rs b/src/types/checker/inference/expr/mod.rs index 644d479fc8..ed5007274b 100644 --- a/src/types/checker/inference/expr/mod.rs +++ b/src/types/checker/inference/expr/mod.rs @@ -135,14 +135,14 @@ impl Checker { for c in conditions { self.infer_type(c, env)?; } - let ty = self.infer_type(result, env)?; + let ty = self.match_arm_result_type(result, env)?; result_ty = Some(match result_ty { Some(acc) => merge_match_arm_result_type(acc, ty), None => ty, }); } if let Some(d) = default { - let ty = self.infer_type(d, env)?; + let ty = self.match_arm_result_type(d, env)?; result_ty = Some(match result_ty { Some(acc) => merge_match_arm_result_type(acc, ty), None => ty, @@ -699,6 +699,21 @@ impl Checker { .unwrap_or(PhpType::Mixed) } + /// Infers a match arm result type for arm merging. Throw arms produce no + /// value, so their checker type (`Void`, shared with `null`) is normalized + /// to `Never` here: the merge must distinguish "arm never yields" (defer + /// to the other arms) from "arm yields null" (keep the merge nullable). + fn match_arm_result_type( + &mut self, + result: &Expr, + env: &TypeEnv, + ) -> Result { + let ty = self.infer_type(result, env)?; + if matches!(result.kind, ExprKind::Throw(_)) { + return Ok(PhpType::Never); + } + Ok(ty) + } } /// Returns `true` if `index` is a valid string offset index for a string receiver. @@ -716,19 +731,41 @@ fn is_valid_string_offset_index(index: &Expr, idx_ty: &PhpType) -> bool { } /// Merges two match arm result types: identical arms keep their type, -/// `Never`-typed arms (`throw`) and `Void`-typed arms (checker `null`) defer -/// to the other arm's type, and any other heterogeneous pair widens to -/// `Mixed` so each arm's runtime value survives instead of being coerced to -/// the first arm's type. +/// `Never`-typed arms (`throw`, normalized at the call site) defer to the +/// other arm's type, `Void`-typed arms (checker `null`) keep the merge +/// nullable so the null arm's value survives return-type-driven coercion +/// (mirroring the lowered temp's nullable-aware merge), and any other +/// heterogeneous pair widens to `Mixed` so each arm's runtime value survives +/// instead of being coerced to the first arm's type. fn merge_match_arm_result_type(acc: PhpType, next: PhpType) -> PhpType { if acc == next { return acc; } - if matches!(acc, PhpType::Void | PhpType::Never) { + if acc == PhpType::Never { return next; } - if matches!(next, PhpType::Void | PhpType::Never) { + if next == PhpType::Never { return acc; } + if acc == PhpType::Void { + return nullable_match_arm_type(next); + } + if next == PhpType::Void { + return nullable_match_arm_type(acc); + } PhpType::Mixed } + +/// Widens a match arm type to also admit PHP null, for merges where another +/// arm is a `null` literal. +fn nullable_match_arm_type(ty: PhpType) -> PhpType { + match ty { + PhpType::Mixed => PhpType::Mixed, + PhpType::Union(members) if members.contains(&PhpType::Void) => PhpType::Union(members), + PhpType::Union(mut members) => { + members.push(PhpType::Void); + PhpType::Union(members) + } + other => PhpType::Union(vec![other, PhpType::Void]), + } +} diff --git a/tests/codegen/control_flow/match_expressions.rs b/tests/codegen/control_flow/match_expressions.rs index d8e98a8012..d91bd4448a 100644 --- a/tests/codegen/control_flow/match_expressions.rs +++ b/tests/codegen/control_flow/match_expressions.rs @@ -253,6 +253,126 @@ echo gettype($w); assert_eq!(out, "integer|NULL"); } +/// Tests that a null arm survives a return with an inferred type: the checker +/// must infer a nullable merge (like the lowered temp) instead of dropping the +/// null arm and coercing its value to the other arm's type. +#[test] +fn test_match_null_and_string_arms_inferred_return_keeps_null() { + let out = compile_and_run( + r#" null, + default => "s", + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "NULL|string"); +} + +/// Tests the mirror arm order with an int value arm: the inferred return type +/// must stay nullable so the null default is not coerced to int 0. +#[test] +fn test_match_int_and_null_default_inferred_return_keeps_null() { + let out = compile_and_run( + r#" 7, + default => null, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "integer|NULL"); +} + +/// Tests that heterogeneous arm values survive being consumed (not just type +/// probed): a corrupted boxed payload with a correct tag would pass the +/// gettype tests but fail here. +#[test] +fn test_match_heterogeneous_arm_values_consumed() { + let out = compile_and_run( + r#" "s", + default => 7, + }; +} +echo pick(0), "|", pick(1); +"#, + ); + assert_eq!(out, "s|7"); +} + +/// Tests that float and int match arms keep their own types and values: the +/// (Int, Float) pair must widen to Mixed, not unify to float. +#[test] +fn test_match_float_and_int_arms_preserve_types_and_values() { + let out = compile_and_run( + r#" 6.5, + default => 8, + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)), "|", pick(0), "|", pick(1); +"#, + ); + assert_eq!(out, "double|integer|6.5|8"); +} + +/// Tests a heterogeneous match nested as another match's arm result: the +/// inner match must contribute its merged (Mixed) type to the outer merge +/// instead of a scalar-biased syntactic fallback re-introducing the #488 +/// fatal cast for nested arms. +#[test] +fn test_match_nested_heterogeneous_match_arm() { + let out = compile_and_run( + r#" match(0) { + 0 => new stdClass(), + default => 1, + }, + default => "s", + }; +} +echo gettype(pick(0)), "|", gettype(pick(1)); +"#, + ); + assert_eq!(out, "object|string"); +} + +/// Pins the documented int/bool arm-merge incompatibility (see "Known +/// incompatibilities with PHP" in docs/php/types.md): int and bool arms share +/// one runtime representation, so both arms observe it — PHP would print +/// "integer|boolean" here. If this test starts failing with PHP's output, the +/// incompatibility got fixed: update the docs entry alongside. +#[test] +fn test_match_int_and_bool_arms_merge_documented_divergence() { + let out = compile_and_run( + r#" 42, + default => true, +}; +$w = match($argc) { + 99 => 42, + default => true, +}; +echo gettype($r), "|", gettype($w); +"#, + ); + assert_eq!(out, "boolean|boolean"); +} + /// Tests that heterogeneous scalar match arms stay heap-balanced: the boxed /// per-arm values and the hidden Mixed temp must release cleanly. #[test] From ae0cac4d99d366c08d07a278d5d7b186c984a044 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Wed, 8 Jul 2026 23:47:32 +0200 Subject: [PATCH 5/5] docs: document match arm representation merges as known incompatibilities int/bool arms and indexed-array arms with different element types merge to one runtime representation, diverging from PHP; moved that claim out of the control-structures match section's "matching PHP" sentence and into the known-incompatibilities list. The int/bool divergence is pinned by test_match_int_and_bool_arms_merge_documented_divergence. --- docs/php/control-structures.md | 8 ++++++-- docs/php/types.md | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/php/control-structures.md b/docs/php/control-structures.md index 72061fa066..6f38cfedbf 100644 --- a/docs/php/control-structures.md +++ b/docs/php/control-structures.md @@ -208,8 +208,12 @@ If no arm matches and there is no `default`, elephc aborts with a fatal runtime Arms may produce values of different types (objects, arrays, strings, ints, `null`), and an arm may be a `throw` expression. When the arm types are heterogeneous, the result is stored as a boxed `mixed` value and each value-producing arm keeps its -own runtime type, matching PHP. Arms whose types share one runtime representation -(two array types, or `int` and `bool`) merge to that representation instead. +own runtime type, matching PHP; a `null` arm keeps the merged result nullable, so +returning such a match from a function with an inferred return type preserves the +null. Exception: arms whose types share one runtime representation (two array +types with different element types, or `int` and `bool`) merge to that +representation, which can change an arm value's observable type — see +[Known incompatibilities with PHP](types.md#known-incompatibilities-with-php). ## try / catch / finally / throw diff --git a/docs/php/types.md b/docs/php/types.md index 864c6765b8..76db5baa51 100644 --- a/docs/php/types.md +++ b/docs/php/types.md @@ -240,6 +240,7 @@ Narrowing applies to function and method parameters. A parameter whose call site - Plain array numeric casts (`(int)$array`, `(float)$array`) follow elephc's existing array cast semantics (return the element count rather than PHP's `0`/`1`). Direct `iterable` numeric casts use PHP's empty/non-empty `0`/`1` semantics. - `__destruct` runs when an object's refcount reaches zero (scope exit, reassignment, `unset`, program end), matching PHP's timing, but **object resurrection is not supported**: re-storing `$this` so the object would outlive the destructor does not keep it alive — the object is still freed once `__destruct` returns. - Under the compatibility `--null-repr=sentinel` opt-out, the integer `9223372036854775806` (`PHP_INT_MAX - 1`) collides with elephc's internal null marker in unboxed scalar slots and is misread as `null` by `echo`, `var_dump()`, `is_null()`, `??`, and related null checks. The default tagged null representation does not have this collision: the full 64-bit integer range round-trips. +- `match` (and ternary) arms whose types share one runtime representation merge to it instead of each arm keeping its own type: an `int` arm together with a `bool` arm collapses to one representation (`match($n) { 1 => 42, default => true }` yields `bool(true)` where PHP keeps `int(42)`), and two indexed-array arms with different element types merge to one element type (`match($n) { 1 => [1, 2], default => ["a", "b"] }` reads the int-array arm's elements through the string-array type). Arms of otherwise distinct types — object, array, string, int, float, `null` — each keep their own runtime type, matching PHP. - Variable variables (`$$name`, `${$expr}`) are not supported. elephc allocates each local to a fixed compile-time stack slot and keeps no per-frame variable-name table, so a variable whose name is computed at runtime cannot be resolved. Use an array keyed by the dynamic name instead. - `serialize()`/`unserialize()` cover scalars, arrays, and objects (including the `__serialize`/`__unserialize`/`__sleep`/`__wakeup` magic methods and `r:`/`R:` object back-references) byte-for-byte compatibly with PHP. Remaining gaps: a cyclic reference inside an object's own properties resolves to `null` on `unserialize()` (serialization handles cycles), the deprecated `Serializable` interface (`C:` wire form) is unsupported, writing a property of an unserialized object held in a `Mixed` does not persist (a separate `Mixed` property-write limitation), and `unserialize()` does not emit PHP's `E_WARNING` / `E_NOTICE` on malformed input — it just returns `false`.