Skip to content

feat(parser): support PHP's (object) cast - #988

Merged
nahime0 merged 2 commits into
mainfrom
fix/836-object-cast
Sep 14, 2026
Merged

nahime0 merged 2 commits into
mainfrom
fix/836-object-cast

Conversation

@Guikingone

@Guikingone Guikingone commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #836.

What was wrong

(object) was rejected during parsing, so the issue's reproduction failed before the cast was ever considered:

$ target/debug/elephc --check repro.php
error[3:19]: Expected ']'

Approach

The conversion is an elephc-PHP prelude, not a runtime helper. PHP's rules are a key walk plus dynamic property writes, both of which already exist as ordinary elephc-PHP, so every supported target gets the cast with no per-target assembly — the same reason var_export is a prelude.

PHP's semantics, reproduced exactly:

  • an array becomes a stdClass whose property names are the array's keys rendered as strings, so (object) ['x', 'y'] has the properties 0 and 1;
  • null becomes an empty stdClass;
  • every other non-object value becomes a stdClass carrying it under the literal scalar property;
  • an object is returned unchanged(object) $o === $o holds and no copy is made.

Two helpers keep the cast's static type precise. __elephc_cast_object is declared : stdClass and is what lower_cast calls when the source cannot be an object, so (object) ['a' => 1] types as stdClass and property reads stay on the nominal path; only a mixed/union source needs the : mixed __elephc_cast_object_dynamic, whose identity arm can return any class. An object source is lowered to the value itself, with no call at all.

The source is lowered once into a synthetic local and the helper call then names that local — the rewrite ref_place_args already uses — so the ordinary user-call path owns argument lowering, the return type, and owned-temporary release, and a source with a side effect cannot run twice.

The cast has no call syntax, so the reachability walk records both helpers when it sees an object cast; without that the prelude was pruned and the lowered call resolved to nothing. Detection of the cast itself rides on opcache_prelude::detect's single exhaustive walk (SymbolKind::ObjectCast) so no second traversal can drift out of step with the AST, and the prelude stays pay-for-use.

Verification

Output was diffed against reference PHP 8.5 for a fixture covering every arm (array literal, array variable, integer keys, each scalar type, null, object identity, instanceof/get_class, and the (array) round trip). It matches byte for byte, including object handle numbering.

  • 13 new end-to-end codegen tests in tests/codegen/casts_and_constants/object_cast.rs
  • 1 new parser test asserting the Cast { target: Object } node
  • 3 new prelude unit tests (pay-for-use detection, detection inside a function body, the : stdClass signature)
  • cargo test --lib (1679), --test parser_tests (397), --test error_tests (1518), --test codegen_tests casts_and_constants (117) and dead_strip (9) all pass; cargo build is warning-free

Out of scope

Two pre-existing gaps were confirmed on main and are untouched by this change:

  • var_dump() of a stdClass carrying dynamic properties prints (0) { }. This reproduces with a plain new stdClass(); $o->x = 1; and no cast at all, so it is a separate var_dump bug rather than a cast one.
  • The eval() bridge parses only scalar casts — (array) already fails there with Parse error: eval() fragment is invalid, and (object) does the same. Adding one without the other would be asymmetric; both belong in a single follow-up.

Closes #836.

`(object)` was rejected during parsing, so `(object) ['name' => 'Laravel']`
failed with `Expected ']'` before the cast was ever considered.

The conversion is implemented as an elephc-PHP prelude rather than a runtime
helper: PHP's rules are a key walk plus dynamic property writes, both of which
already exist as ordinary elephc-PHP, so every supported target gets the cast
with no per-target assembly — the same reason `var_export` is a prelude.

PHP's semantics, reproduced exactly:

- an array becomes a stdClass whose property names are the array's keys
  rendered as strings, so `(object) ['x', 'y']` has the properties `0` and `1`;
- `null` becomes an empty stdClass;
- every other non-object value becomes a stdClass carrying it under the literal
  `scalar` property;
- an object is returned UNCHANGED — `(object) $o === $o` holds and no copy is
  made.

Two helpers keep the cast's static type precise. `__elephc_cast_object` is
declared `: stdClass` and is what `lower_cast` calls when the source cannot be
an object, so `(object) ['a' => 1]` types as `stdClass` and property reads stay
on the nominal path; only a `mixed`/union source needs the `: mixed`
`__elephc_cast_object_dynamic`, whose identity arm can return any class. An
object source is lowered to the value itself, with no call at all.

The source is lowered ONCE into a synthetic local and the helper call then
names that local — the rewrite `ref_place_args` already uses — so the ordinary
user-call path owns argument lowering, the return type, and owned-temporary
release, and a source with a side effect cannot run twice.

The cast has no call syntax, so the reachability walk records both helpers when
it sees an object cast; without that the prelude was pruned and the lowered
call resolved to nothing. Detection of the cast itself rides on
`opcache_prelude::detect`'s single exhaustive walk (`SymbolKind::ObjectCast`)
so no second traversal can drift out of step with the AST, and the prelude
stays pay-for-use.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@github-actions github-actions Bot added area:optimizer Touches AST or EIR optimization passes. area:parser Touches parsing or AST construction. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities. labels Sep 13, 2026
@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds PHP-compatible (object) casts throughout the compiler pipeline.

  • Parses (object) as a case-insensitive cast while preserving object as an ordinary identifier token.
  • Infers and lowers object casts using pay-for-use prelude helpers, including casts introduced through compile-time autoloading.
  • Implements array-key projection, scalar wrapping, null conversion, and object identity semantics.
  • Adds documentation, a runnable example, end-to-end behavior tests, malformed-syntax tests, and heap-debug ownership coverage.
  • All previous review findings are resolved in the current code.

Confidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, ownership, or repository-rule issue remains.

The post-review changes fully address the prior autoload-helper failure, and the previously requested lexer, parser, error, example, and ownership coverage is present. All previous threads are resolved, and no new changes exist beyond the reviewed head.

Important Files Changed

Filename Overview
src/parser/expr/calls.rs Recognizes the case-insensitive object cast spelling through the existing cast parser.
src/object_cast_prelude.rs Defines and conditionally injects the static and dynamic PHP helpers implementing object-cast semantics.
src/ir_lower/expr/ternary_cast.rs Preserves object identity and lowers non-object sources once through the appropriate helper.
src/pipeline.rs Injects object-cast helpers after autoload expansion so casts in loaded classes are covered.
src/optimize/reachability/usage/expressions.rs Keeps the synthetically called cast helpers reachable during prelude pruning.
tests/codegen/casts_and_constants/object_cast.rs Covers PHP conversion semantics, identity, evaluation count, helper reachability, and autoloaded casts.
tests/codegen/runtime_gc/object_cast.rs Exercises ownership and cleanup for arrays, strings, mixed values, objects, loops, and dropped results.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[PHP object cast] --> B[Parser: CastType::Object]
    B --> C[Type inference]
    C --> D{Source statically object?}
    D -- Yes --> E[Return source unchanged]
    D -- No --> F{Mixed or union?}
    F -- Yes --> G[Dynamic cast helper]
    F -- No --> H[Static cast helper]
    G --> I[Object identity or conversion]
    H --> J[stdClass conversion]
    I --> K[EIR and native codegen]
    J --> K
Loading

Reviews (3): Last reviewed commit: "fix(object-cast): inject past autoload, ..." | Re-trigger Greptile

Comment thread src/pipeline.rs Outdated
Comment thread tests/parser_tests/expressions/basics.rs
Comment thread src/parser/ast/expr.rs
Comment thread src/ir_lower/expr/ternary_cast.rs
@Guikingone Guikingone self-assigned this Sep 13, 2026
…er ownership

Review follow-up on #988.

Autoloaded casts missed the helpers. The prelude sat with every other one,
before name resolution — but the cast is detected SYNTACTICALLY and an
autoloaded class file is not part of the AST until `autoload::run`. A program
whose only `(object)` cast lived in a PSR-4 class therefore got no injection,
and codegen failed with `unsupported EIR backend feature: language construct
__elephc_cast_object`. Injection moved past `autoload::run`, with the
declarations name-resolved on the way in exactly as `autoload::run` resolves the
files it splices. The three test harnesses that mirror the pipeline moved with
it.

The helper names are now reserved. `ir_lower` lowers every `(object)` cast to a
call on them, so a user function of the same name would not merely shadow the
prelude — it would BECOME the cast's semantics. A program that declares either
one and also spells a cast is rejected by name; declaring one WITHOUT a cast is
still fine, since the prelude is pay-for-use. `detect::first_declaration` is
added alongside `program_declares` so the diagnostic can point at the offending
declaration.

The dynamic helper corrupted an object payload's refcount. Its `is_object()`
arm returned the parameter early, and a `mixed` function with a CONDITIONAL
`return $param;` alongside another return miscompiles today — heap debug reports
`bad refcount`. The body now converts in place and returns `$value` once, the
spelling that is clean. This is a PRE-EXISTING return-alias hole reachable from
ordinary user PHP (`function f(mixed $v): mixed { if (is_object($v)) { return
$v; } return $v; }` reproduces it with no cast involved); the helper is written
to stay out of it rather than to work around it, and the hole is reported
separately.

Tests: the required lexer and error coverage the repository asks of a new
language construct (the cast's token window and case handling; a missing
operand, an unterminated window, and the reserved-name diagnostic), an
`examples/object-cast/` program whose output was diffed against reference PHP,
six ownership fixtures under `tests/codegen/runtime_gc/` asserting a clean heap
for a temporary array, a repeated cast, an owned string, each arm of a Mixed
source, an object source that must be neither copied nor released, and a result
dropped at the end of a function, and a multi-file fixture pinning the autoload
case.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone added a commit that referenced this pull request Sep 13, 2026
…er ownership

Review follow-up on #988.

Autoloaded casts missed the helpers. The prelude sat with every other one,
before name resolution — but the cast is detected SYNTACTICALLY and an
autoloaded class file is not part of the AST until `autoload::run`. A program
whose only `(object)` cast lived in a PSR-4 class therefore got no injection,
and codegen failed with `unsupported EIR backend feature: language construct
__elephc_cast_object`. Injection moved past `autoload::run`, with the
declarations name-resolved on the way in exactly as `autoload::run` resolves the
files it splices. The three test harnesses that mirror the pipeline moved with
it.

The helper names are now reserved. `ir_lower` lowers every `(object)` cast to a
call on them, so a user function of the same name would not merely shadow the
prelude — it would BECOME the cast's semantics. A program that declares either
one and also spells a cast is rejected by name; declaring one WITHOUT a cast is
still fine, since the prelude is pay-for-use. `detect::first_declaration` is
added alongside `program_declares` so the diagnostic can point at the offending
declaration.

The dynamic helper corrupted an object payload's refcount. Its `is_object()`
arm returned the parameter early, and a `mixed` function with a CONDITIONAL
`return $param;` alongside another return miscompiles today — heap debug reports
`bad refcount`. The body now converts in place and returns `$value` once, the
spelling that is clean. This is a PRE-EXISTING return-alias hole reachable from
ordinary user PHP (`function f(mixed $v): mixed { if (is_object($v)) { return
$v; } return $v; }` reproduces it with no cast involved); the helper is written
to stay out of it rather than to work around it, and the hole is reported
separately.

Tests: the required lexer and error coverage the repository asks of a new
language construct (the cast's token window and case handling; a missing
operand, an unterminated window, and the reserved-name diagnostic), an
`examples/object-cast/` program whose output was diffed against reference PHP,
six ownership fixtures under `tests/codegen/runtime_gc/` asserting a clean heap
for a temporary array, a repeated cast, an owned string, each arm of a Mixed
source, an object source that must be neither copied nor released, and a result
dropped at the end of a function, and a multi-file fixture pinning the autoload
case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@Guikingone
Guikingone requested a review from nahime0 September 13, 2026 16:51

@nahime0 nahime0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@nahime0
nahime0 merged commit 14c3e7b into main Sep 14, 2026
148 checks passed
@nahime0
nahime0 deleted the fix/836-object-cast branch September 14, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:optimizer Touches AST or EIR optimization passes. area:parser Touches parsing or AST construction. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser: support the PHP object cast

2 participants