Skip to content

fix: match with heterogeneous arm types no longer coerces every arm to one unified type#492

Open
mirchaemanuel wants to merge 5 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/488-match-heterogeneous-mixed
Open

fix: match with heterogeneous arm types no longer coerces every arm to one unified type#492
mirchaemanuel wants to merge 5 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/488-match-heterogeneous-mixed

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Fixes #488.

Root cause

lower_match typed its hidden merge temp with the syntactic expression-type join (fallback_expr_typeinfer_expr_type_syntactic), whose wider_type_syntactic fold is scalar-biased: any Str operand collapses the whole join to Str (and it can never produce Mixed). With arms {object, array, string} the temp became Str, so store_expr_into_tempcoerce_value_for_temp emitted an Op::Cast to string for the object arm — the runtime fatal from the issue — and a silent string coercion for the array arm. The full pairwise matrix from the issue reproduced exactly (Str absorbs everything; otherwise the last-folded arm's concrete type wins).

The type checker's ExprKind::Match inference had an independent bug in the same construct: it returned the first arm's type and ignored every other arm, which is why the heterogeneous match with an inferred return type failed to compile (runtime_call from PHP type Str to PHP type Object) — two different wrong unifications colliding.

The ternary lowering already solves this correctly via branch_merge_result_typewider_type_for_merge, which merges heterogeneous heap types to Mixed so each branch value is boxed as-is (Op::MixedBox) instead of cast. This PR brings match to that exact model.

Changes

  • src/ir_lower/expr/mod.rs — new match_merge_result_type() sitting next to branch_merge_result_type(): folds materialized_expr_type_for_merge over all arms + default with nullable_aware_branch_merge_type, keeps nullable merges, and reconciles non-nullable merges with the syntactic fallback via wider_type_for_merge (which can only keep or widen — the Str-absorbing fallback can no longer narrow the temp). lower_match now uses it for the hidden temp. materialized_expr_type_for_merge also gains an explicit ExprKind::Match arm (mirroring the existing Ternary arm) so a match nested inside a ternary/coalesce merge uses the same rule instead of falling into the syntactic catch-all.
  • src/types/checker/inference/expr/mod.rsExprKind::Match inference now folds all arm types with merge_match_arm_result_type(): identical arms keep their type, Never/Void arms (throw / null) defer to the other arms, any other heterogeneous pair widens to Mixed. This keeps the checker and EIR lowering in agreement for inferred return types and assignments.
  • src/codegen/lower_inst/builtins.rslower_gettype now dispatches on ty.codegen_repr() instead of the raw PHP type. A nullable-int union (null|int) stores an inline tagged scalar, but the raw-type dispatch matched Union(_) and routed it to the boxed-Mixed path, calling __rt_mixed_unbox on a non-pointer payload → segfault. This was a pre-existing hole (the ternary twin $v = ($argc == 1) ? 1 : null; echo gettype($v); segfaults on current main), but the match fix newly routed top-level match int/null results into it, so it is fixed here with regression tests for both constructs. Verified under both --null-repr=sentinel and --null-repr=tagged.
  • tests/codegen/control_flow/match_expressions.rs (new) — 13 tests: the issue repro, every pairwise arm mix from the characterization matrix (object/string both orders, object/array, object/int, array/int, string/int, object/null), top-level and inferred-return-type shapes, homogeneous-arm and throw-default no-regression guards, a heap-debug balance test, and the gettype tagged-scalar regression. Plus test_ternary_int_null_gettype in ternary.rs for the pre-existing ternary crash.
  • docs/php/control-structures.md — documents heterogeneous arm behavior (boxed mixed result, per-arm runtime types, same-representation merges).

Behavior after the fix

Arms before after PHP
object / string runtime fatal object|string object|string
object / array array|array (silent) object|array object|array
object / int compile error object|integer object|integer
array / int integer|integer (silent) array|integer array|integer
string / int string|string (silent) string|integer string|integer
object / null object|object (silent) object|NULL object|NULL
heterogeneous, inferred return compile error works works
int / null + gettype integer / segfault (ternary) integer|NULL integer|NULL

Validation

  • 13 new match tests + 1 ternary regression: green on macOS ARM64 and Linux x86_64 (Docker, control_flow suite).
  • Focused regression filters all green: match (114), ternary (36), enum (46), coalesce (52), gettype (16), error_tests match (21).
  • An adversarial review pass probed nesting (match-in-ternary, ternary-in-match, match-in-match), constant-folded subjects, --ir-opt=on|off (byte-identical outputs), declared ?stdClass returns, throw arms inside try/catch, and heap balance — no new leaks and no checker/lowering disagreement found.

Adjacent pre-existing findings (out of scope, kept out of this PR)

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.
…ties

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.
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Pushed three follow-up commits after a multi-agent review of this branch:

  • 57f6667 docs: regenerate builtins catalog — fixes the failing Builtins docs in sync gate: the 3-line comment added in lower_gettype shifted every downstream lowering function's line anchor by +3, so the 24 generated pages needed regeneration (pure line-number changes, no semantic drift).
  • a32c2b6 fix: match null arms keep the checker's inferred merge nullablematch() { 0 => null, default => "s" } returned through an inferred return type printed string|string (PHP: NULL|string): the checker's arm merge typed both null and throw as Void and dropped them. Throw arms now normalize to Never at the merge call site; genuine null arms widen the merge to a nullable union, mirroring the lowered temp's nullable-aware merge. Adds tests for that plus value-consumption, float/int arms, and nested heterogeneous match.
  • ae0cac4 docs: document match arm representation merges as known incompatibilities — int/bool arms and indexed-array arms with different element types still merge to one representation (PHP keeps per-arm types); moved that out of the "matching PHP" sentence into the known-incompatibilities list, with a pinning test.

Two confirmed pre-existing items intentionally not addressed here (both reproduce at merge-base too, via the shared boxing/merge paths):

  1. object-into-Mixed boxing leaks the payload + box on every evaluation (--heap-debug shows live_blocks growing per iteration for object arms; same leak shape for ternary object branches and : mixed object returns);
  2. two indexed-array arms with different element types miscompile the read (match(){1 => [1,2], default => ["a","b"]} reads the int arm through Array<string>) — now documented as a known incompatibility; merging those arms to Mixed would fix it.

Happy to open follow-up issues for either if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

match with heterogeneous mixed arms (object/array/string) dies at runtime coercing the object arm to string

1 participant