Skip to content

feat: dynamic class member reads, $obj::m() runtime dispatch and new class-name references - #1916

Open
JGamboa wants to merge 9 commits into
illegalstudio:mainfrom
JGamboa:fix/dynamic-class-access
Open

JGamboa wants to merge 9 commits into
illegalstudio:mainfrom
JGamboa:fix/dynamic-class-access

Conversation

@JGamboa

@JGamboa JGamboa commented Oct 19, 2026

Copy link
Copy Markdown

Summary

Class members reached through a runtime receiver, and the new class-name references PHP's grammar allows.

Dynamic class member reads. $class::NAME, $class::$prop, $obj::NAME and $obj::$prop failed with "Dynamic class access ($class::X) is only supported for method calls". The parser now desugars them into internal primitives (__elephc_dynamic_class_constant, __elephc_dynamic_static_property). ir_lower::expr::dynamic_class_access expands each one:

  • the receiver is evaluated once and reduced to its runtime class name (get_class() for an object, the string minus one leading \ for a class string, a runtime split for mixed/union receivers);
  • the name is matched case-insensitively against every AOT class-like that resolves the member (own, inherited, interface constants, enum cases);
  • each arm lowers the ordinary C::NAME / C::$prop read;
  • PHP's visibility rules apply from the calling scope ("Cannot access private constant ...");
  • $this::NAME is static::NAME, and $this::$prop goes through the runtime class.

$obj::method() dispatches on the receiver's runtime class. It desugared to call_user_func([$obj, 'm']), which failed at runtime with "callable array did not resolve to an invokable target". The new __elephc_dynamic_static_call primitive matches the runtime class against every class that declares or inherits m:

  • a static m becomes C::m(...), so late static binding sees the subclass;
  • an instance m follows PHP's "call statically" rules;
  • arity is checked per candidate, surplus arguments are evaluated, and too few throw ArgumentCountError;
  • by-reference parameters and spreads into them are refused with PHP's error;
  • an unmatched name throws Call to undefined method X::m().

$cls::$method() (a variable method name) keeps the call_user_func path.

String offsets read from mixed. f()[0] with f(): mixed returning a string printed an empty value, because __rt_mixed_array_get only understands containers. A boxed receiver that may hold a string now splits at runtime: a string payload takes the string-offset read (warnings preserved, ?? sees null on a silent miss), and anything else keeps the container path. String-union receivers are covered too.

new class-name references. PHP's new_variable grammar, which elephc rejected with "Dynamic class-name expressions after 'new' are not supported":

  • new static::$builder($x), new self::$builder, new C::$builder;
  • new $obj->cls(...), new $this->class(...) (a reserved word is a valid property name here), new $obj->map['k'](...), new $classes[0];
  • chains after a static property: new static::$classes[static::class]($m) (Eloquent HasCollection).

Writes are refused, not miscompiled. The read desugar is a value, so every write through it would land in a temporary. The following are compile errors with a targeted message:

  • $class::$prop = v, [] =, [k] =, ++/--;
  • =&, by-reference foreach, destructuring into it;
  • builtin by-reference arguments (settype($c::$p['n'], ...)), unset().

Docs: docs/php/classes.md (dynamic instantiation and member access) and docs/internals/the-parser.md. The three internal primitives get generated builtin docs pages (docs(builtins) commit, regenerated with scripts/docs/extract_builtins.py --render --force, gen_module_sections.py and gen_php_comparison.py).

Known limitation: when no class matches, PHP reports Class "X" not found for an unknown class and Undefined constant X::NAME / Access to undeclared static property X::$p for a known class without the member. This path reports the member form in both cases, because telling them apart needs a runtime lookup of every class name at each site. class_exists() with a runtime string is not available in AOT code. The test that covers it (test_dynamic_class_member_unknown_class_throws_catchable_error_elephc_message) is documented as pinning elephc's message, not PHP's.

Question for review: do you want the exact per-site check (a table of every class name consulted at each dynamic site, so an unknown class reports Class "X" not found)? It is doable, but it costs code size at every $class::NAME / $class::$p site, so I left it out unless you prefer exactness here.

Closes #850
Closes #1455
Part of #844 (the $class::NAME part; self::$method() and the other dynamic member names are in the dynamic members PR).
Closes #1896
Closes #1897

Repros (#1896, #1897)

$obj::staticMethod() fails at runtime on main:

<?php
class Logger {
    public static function version(): string { return static::class . " 1.0"; }
}
class FileLogger extends Logger {}
$logger = new FileLogger();
echo $logger::version(), "\n";

main: Fatal error: callable array did not resolve to an invokable target. php 8.5: FileLogger 1.0.

A string offset read on a mixed value is empty on main:

<?php
function version(): mixed { return "3.1.0"; }
echo version()[0], "\n";
$v = version();
echo $v[2], "\n";

main prints two empty lines. php 8.5: 3 and 1.

Left out

Tests

This branch is based on main @ df57a98. These tests fail on that main and pass on this branch:

  • codegen_tests oop::dynamic_class_members: all 10 tests.
  • codegen_tests parser_expr: test_mixed_receiver_subscript_reads_string_array_and_array_access, test_mixed_receiver_subscript_silent_and_union_string, test_mixed_receiver_subscript_string_arm_is_leak_free, test_mixed_receiver_subscript_string_miss_warns, test_dynamic_receiver_static_method_call, test_dynamic_receiver_static_call_by_ref_argument_throws, test_dynamic_receiver_static_call_is_accepted_by_strict_php, test_new_with_dynamic_class_name_reference, test_new_with_extended_class_name_reference, test_new_with_this_property_class_reference, test_dynamic_member_read_respects_visibility, test_this_static_property_uses_runtime_class, test_dynamic_static_call_respects_visibility, test_dynamic_static_call_arity_per_candidate, test_dynamic_class_name_strips_one_leading_backslash, test_dynamic_static_call_overridden_instance_method_is_refused, test_dynamic_static_call_by_ref_candidate_is_arm_local, test_dynamic_static_call_inherited_private_late_static, test_dynamic_static_call_spread_into_by_ref_throws, test_dynamic_static_property_unset_is_refused.
  • error_tests dynamic_class_members: all 4 tests.
  • parser_tests expressions::test_dynamic_static_call_desugars_to_dynamic_static_call_primitive.

Controls (already pass on main, marked in their doc comments): test_new_plain_forms_still_work, test_dynamic_receiver_variable_method_name_still_works, test_dynamic_static_call_variadic_is_not_required, parser_tests expressions::test_dynamic_static_call_with_variable_method_desugars_to_call_user_func. misc::classes::test_error_dynamic_new_rejects_unsupported_class_name_references now uses a shape PHP's grammar also rejects (new $box->className::FOO), because new $box->className and new $classes[0] compile now.

The builtin contract counts in elephc-builtin-contract (registry.rs, support.rs) include the three new internal primitives.

Gate:

  • cargo build (no new warnings);
  • error_tests, parser_tests, lexer_tests, cargo test -p elephc-builtin-contract (with and without --features curl);
  • the codegen tests matching dynamic, oop::, array_access, string_offset, strict_php and late_static;
  • the "Builtins docs in sync" steps: regenerate, audit, validate_site_compat, the unit tests and audit_builtin_eir_boundary.py --enforce-target-architecture.

Refs #1886

AI assistance

This change was prepared with substantial help from Claude Code (Anthropic): the code was ported from the #1886 branch and adapted to current main with its assistance. I reviewed the diff, and the regression tests were run against main and against this branch.

🤖 Generated with Claude Code

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:eir Touches EIR definitions, lowering, validation, or passes. area:parser Touches parsing or AST construction. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities. labels Oct 19, 2026
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The PR changes compiler behavior for several PHP expression forms.

Fix the magic fallback's argument checks before merging.

Fix All in Claude CodeFindings

  1. P1 Magic calls skip argument checks ▶
Fix with agent prompt
### Issue 1
src/ir_lower/expr/dynamic_class_access.rs:789-798
`call_static_magic_expr` collects unpacked arguments as an array literal, which skips PHP's call-time checks. Pass `['x' => 1, 0 => 2]` through an `array` parameter to `$c::missing(...$args)` on a class with `__callStatic`: this path runs the handler, but PHP throws because a positional argument follows a named argument. Separate spreads with the same string key also silently overwrite each other.

Use the shared call-unpacking checks before building the handler's argument array.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds runtime class-member reads, runtime-class method calls, extended new references, and string-offset reads from mixed values.

  • Runtime class names now select the constants and static properties a PHP expression reads.
  • Literal method names after :: now call methods on the receiver’s runtime class.
  • new now accepts class names built from properties, array entries, and static properties.
  • String offsets now work when a mixed receiver holds a string.
  • Generated builtin reference pages now include the updated catalog metadata.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Runtime receiver"] --> B["Evaluate once and find class name"]
    B --> C["Match an AOT class"]
    C --> D["Read constant or static property"]
    C --> E{"Method accessible?"}
    E -- Yes --> F["Ordinary method call"]
    E -- "Missing or inaccessible" --> G{"Has __callStatic?"}
    G -- Yes --> H["Collect arguments and call handler"]
    G -- No --> I["Throw Error"]
Loading

Reviews (4) · Last reviewed commit: "docs(examples): show runtime-named class..." · Reviewed by Greptile

Comment thread src/builtins/system/__elephc_dynamic_static_call.rs Outdated
Comment thread src/ir_lower/expr/dynamic_class_access.rs
Comment thread src/ir_lower/expr/dynamic_class_access.rs
Comment thread src/ir_lower/expr/lsb_taint.rs
…me references

- `$class::NAME`, `$class::$prop`, `$obj::NAME` and `$obj::$prop` desugar to the internal
  `__elephc_dynamic_class_constant` / `__elephc_dynamic_static_property` primitives, which
  `ir_lower::expr::dynamic_class_access` expands into a case-insensitive dispatch over the AOT
  classes that resolve the member, with PHP's visibility checks from the calling scope.
- `$obj::method()` dispatches on the receiver's runtime class through
  `__elephc_dynamic_static_call` (late static binding sees the subclass, per-candidate arity,
  PHP's "call statically" rules for instance methods) instead of failing at runtime.
- A boxed `mixed` / string-union subscript receiver that holds a string takes the
  string-offset read instead of answering an empty value.
- `new static::$p(...)`, `new C::$p`, `new $obj->p(...)`, `new $this->class(...)`,
  `new $a['k']` and chains after a static property (`new static::$map[static::class]($m)`)
  build the class the reference names at runtime.
- Every write through the read desugar (`=`, `[]`, `[k]`, `++`, `=&`, by-ref foreach,
  destructuring, builtin by-ref arguments, `unset`) is a compile error instead of a write
  into a temporary.
…e arity errors

- $class::$p under ??, isset() and empty() was lowered as an ordinary read,
  so an uninitialized typed property, an undeclared name or a property
  the scope cannot see raised instead of answering the default. The
  dynamic dispatch now carries the silent-probe mode: each arm uses the
  initialized-property check and the no-match and inaccessible arms
  yield null. An unknown class also yields null there (PHP throws
  Class "X" not found), the same per-site class table trade-off the
  non-silent message already makes.
- A $class::m() arm that raises ArgumentCountError for too few arguments
  skipped the supplied arguments; PHP evaluates them first.
…r call

The primitive runs an arbitrary user method, which may write globals,
the heap, files or output, but its descriptor only declared reads,
allocation and throwing. Passes that consult the descriptor before the
call is expanded must treat it as opaquely as any user call.
@JGamboa
JGamboa force-pushed the fix/dynamic-class-access branch from a84c884 to 7320d96 Compare October 21, 2026 06:04
Comment thread src/ir_lower/expr/dynamic_class_access.rs Outdated
Comment thread src/ir_lower/expr/array_access.rs Outdated
Comment thread src/parser/expr/prefix_complex.rs
The silent probe only applied when $class::$p was the whole operand of
??, isset() or empty(). As the root of a subscript chain
($c::$p['x'] ?? 'd', isset($c::$p['x']), empty(...)) it was read the
ordinary way, so an uninitialized typed property raised instead of
answering the default. The silent subscript receiver now reads that
root in probe mode, and isset() over such a chain reads the offset
silently instead of taking the eager route.
… a mixed read

The string arm of a mixed receiver converted the offset without the
reload the typed string read performs: a warning handler that changed a
boxed offset variable (1.9 to 2.9) still selected the old character.
Both reads now share string_offset_index, which reads the variable
again after the cast.
The runtime-class dispatch only had arms for classes declaring the
method, so $c::get() threw for a class answering through __callStatic,
and an inaccessible method threw without trying the handler. A class
that lacks the method, or whose method the scope cannot reach, now
calls C::__callStatic('m', [args]) with the arguments as PHP collects
them.
Comment on lines +789 to +798
let entries = call_args
.iter()
.map(|arg| match &arg.kind {
ExprKind::Spread(inner) => ArrayEntry::Spread((**inner).clone()),
ExprKind::NamedArg { name, value } => ArrayEntry::Keyed(
Expr::new(ExprKind::StringLiteral(name.clone()), arg.span),
(**value).clone(),
),
_ => ArrayEntry::Value(arg.clone()),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Magic calls skip argument checks

call_static_magic_expr collects unpacked arguments as an array literal, which skips PHP's call-time checks. Pass ['x' => 1, 0 => 2] through an array parameter to $c::missing(...$args) on a class with __callStatic: this path runs the handler, but PHP throws because a positional argument follows a named argument. Separate spreads with the same string key also silently overwrite each other.

Use the shared call-unpacking checks before building the handler's argument array.

Knowledge Base Used: IR lowering and optimization

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ir_lower/expr/dynamic_class_access.rs
Line: 789-798

Comment:
**Magic calls skip argument checks**

`call_static_magic_expr` collects unpacked arguments as an array literal, which skips PHP's call-time checks. Pass `['x' => 1, 0 => 2]` through an `array` parameter to `$c::missing(...$args)` on a class with `__callStatic`: this path runs the handler, but PHP throws because a positional argument follows a named argument. Separate spreads with the same string key also silently overwrite each other.

Use the shared call-unpacking checks before building the handler's argument array.

**Knowledge Base Used:** [IR lowering and optimization](https://app.greptile.com/illegal-studio/-/custom-context/knowledge-base/illegalstudio/elephc/-/docs/ir-lowering-and-optimization.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

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

Labels

area:builtins Touches PHP builtin declarations or emitters. area:eir Touches EIR definitions, lowering, validation, or passes. area:parser Touches parsing or AST construction. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

1 participant