Skip to content

feat(parser): runtime method names on named receivers, named arguments in dynamic calls, foreach place targets - #1917

Open
JGamboa wants to merge 9 commits into
illegalstudio:mainfrom
JGamboa:fix/dynamic-members-calls
Open

JGamboa wants to merge 9 commits into
illegalstudio:mainfrom
JGamboa:fix/dynamic-members-calls

Conversation

@JGamboa

@JGamboa JGamboa commented Oct 19, 2026

Copy link
Copy Markdown

Depends on #1916 (dynamic class member reads and $obj::m() dispatch): this branch is based on it and reuses its __elephc_dynamic_static_call primitive and dispatch helpers.

Summary

Runtime method names on a class named in the source. These forms failed to parse:

  • self::$method(...), which calls the method named by $method;
  • static::{'should' . $unit}(...), parent::{$m}(...), C::{expr}(...) and C::$name(...).

They now desugar to __elephc_dynamic_static_call(<Receiver>::class, $name, ...args). The ::class marker tells the lowering it is the scoped form. ir_lower::expr::scoped_dynamic_calls expands it into a method-name dispatch whose arms are the ordinary Receiver::m(...) calls:

  • static:: keeps late static binding, and self::/parent:: forward it;
  • an instance method runs on $this, and a static context refuses it;
  • visibility and arity are checked per arm, with PHP's Error/ArgumentCountError messages;
  • an unknown name falls back to __call/__callStatic first, then throws Call to undefined method;
  • a static method that only a subclass declares runs under static:: when its body never observes the called class, or when the called class is that subclass. Otherwise it throws an explicit Error rather than binding static to the wrong class.

self::$prop without parentheses is still a static property read. C::{expr} without a call (PHP 8.3's computed constant fetch) is a clear compile error.

Named arguments in $obj->$name(...) / $obj->{$expr}(...). These were rejected because the method is only known at run time. The call becomes call_user_func_array([$obj, $name], $args) with the names as string keys, so they bind against the method resolved at run time:

  • PHP's compile-time argument-order rules are enforced;
  • a spread's string key that a named argument would overwrite throws Named parameter $x overwrites previous argument;
  • a required parameter left unbound throws PHP's ArgumentCountError: <callee>(): Argument #N ($name) not passed. The descriptor invokers used to print a generic message.

Named arguments stay rejected in nullsafe and static dynamic calls.

Callable values called directly. A callable array literal can be called directly: [$class, 'method'](), [self::$s, 'events'](). An untyped property holding a callable array or a Closure is called at run time: ($this->cb)($a).

foreach place targets. foreach ($m as $o->k => $o->v), as $a['k'] => $a['v'], as $o->{$p}, as C::$x and as $list[] failed to parse. The loop now binds hidden temporaries, and the body starts by storing the value and then the key, the order PHP assigns them in. A by-reference value into a place is a clear compile error.

Docs: docs/php/classes.md (dynamic method and static calls), docs/internals/the-parser.md.

Closes #844 (together with #1916)
Closes #847 (the foreach half; the while half is in the statement-forms PR)
Part of #1146: named arguments in $obj->$m(...), including after an unpacked list ($obj->$m(...$rest, a: 1)). $obj->$m(a: 1, ...$rest) is a compile error in PHP itself ("Cannot use argument unpacking after named arguments"), and the static form $class::method(a: 1, ...) is still rejected.

Left out

Tests

This branch is based on the dynamic class access PR, which is based on main @ df57a98. These tests fail on that main and pass on this branch:

  • codegen_tests dyn_member: all 10 tests (test_scoped_computed_method_name_keeps_late_static_binding, test_self_variable_method_call_uses_local_name, test_scoped_dynamic_call_arm_rules, test_scoped_dynamic_call_subclass_late_static_binding_is_refused_per_arm, test_callable_array_literal_direct_call, test_foreach_place_targets, test_dynamic_method_call_named_arguments, test_static_computed_name_reaches_instance_methods, test_named_argument_container_missing_argument_message, test_property_callable_array_and_closure_callees).
  • error_tests misc::functions: test_error_dynamic_static_method_call_rejects_named_arguments (replaces test_error_dynamic_method_call_rejects_named_arguments: instance dynamic calls now accept named arguments), test_error_dynamic_method_call_named_argument_order, test_error_dynamic_member_unsupported_forms.
  • parser_tests expressions: test_scoped_variable_method_call_desugars_to_scoped_dynamic_call, test_scoped_computed_method_name_parses, test_expression_callables_parse, test_dynamic_method_call_with_named_arguments_parses, test_foreach_place_targets_parse.

Gate:

  • cargo build (no new warnings);
  • error_tests, parser_tests, lexer_tests;
  • the codegen tests matching dyn_member, dynamic, foreach, named, static_call, static_prop, strict_php and call_user_func;
  • the "Builtins docs in sync" steps (no generated docs change in this PR);
  • cargo test -p elephc-builtin-contract.

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:eir Touches EIR definitions, lowering, validation, or passes. area:parser Touches parsing or AST construction. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. 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: 0/5

[Medium impact] The PR changes how the compiler accepts and lowers several PHP expression forms.

This PR needs fixes before merging because calls can lose arguments, skip magic methods, or silently lose loop writes.

Fix All in Claude CodeFindings

  1. P1 Runtime calls skip magic methods ▶
  2. P1 Subclass arguments disappear ▶
  3. P1 Loop writes disappear ▶
  4. P1 Calls break existing references ▶
  5. P1 Nested unset checks still throw ▶
  6. P2 String reads use old offsets ▶
Fix with agent prompt
### Issue 1
src/ir_lower/expr/dynamic_class_access.rs:462-470
For `class C { static function __callStatic($name, $args) { return 'ok'; } }`, calling `$c::missing()` through a runtime receiver throws instead of running `C::__callStatic`. The new dispatch only includes classes that declare the requested method, then throws on a miss. Inaccessible methods also throw without trying magic.

Apply the magic fallback after resolving the receiver's class.

### Issue 2
src/ir_lower/expr/scoped_dynamic_calls.rs:263
`static::$m(...)` drops arguments using the enclosing class's parameter count before choosing the subclass method. If `A::f($a)` is overridden by `B::f($a, $b = 0)`, a call inside `A` to `static::$m(1, 2)` cuts the arguments to `[1]`, even when the called class is `B`. The subclass receives its default instead of `2`.

Choose the called class's signature before trimming arguments.

### Issue 3
src/parser/control.rs:417-420
`foreach` accepts an element target rooted in a runtime static-property read without rejecting the write. For example, `foreach ([7] as $c::$items[0]) {}` becomes a `NestedArrayAssign` whose parent is the value returned by `__elephc_dynamic_static_property`, not the static property's storage. The write changes a temporary and leaves the property unchanged.

Call `reject_dynamic_member_write` for place targets, as the append path already does.

### Issue 4
src/parser/expr/dynamic_calls.rs:197-205
The new `RefAssign` binds each named variable to a fresh argument-array entry, even when the method takes that argument by value. This breaks references the caller already holds. After `$n = 1; $alias =& $n; $o->$m(x: $n); $n = 2;`, `$alias` stays `1` instead of becoming `2`.

Passing the same variable to two reference parameters also gives them separate cells. Preserve the caller's existing storage instead of rebinding its variable.

### Issue 5
src/ir_lower/expr/lazy_branches.rs:79-82
The new silent read only works when `$class::$prop` is the whole operand. With `class C { public static array $p; }` and `$c = 'C'`, `$c::$p['x'] ?? 'default'` still reads the uninitialized property normally and throws instead of returning the default. `isset($c::$p['x'])` and `empty($c::$p['x'])` have the same gap.

Route the root property through the silent read when lowering a subscript chain.

### Issue 6
src/ir_lower/expr/array_access.rs:378
The new string branch keeps the old offset after a cast warning runs user code. A boxed float offset can be changed by `set_error_handler`; the existing string-read path reloads that variable afterward, but `string_offset_index` does not. With a `mixed` receiver containing `'abc'`, changing the offset from `1.9` to `2.9` in the handler still selects `'b'` instead of `'c'`.

Reuse the existing reload step for boxed variable offsets.

---

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

Summary

Adds runtime method names on named class receivers, named arguments in dynamic instance calls, callable expressions, and foreach place targets.

  • Scoped calls can choose a method name at runtime.
  • Dynamic instance calls can bind named arguments at runtime.
  • Runtime class names can read class constants and static properties.
  • foreach can store keys and values into writable places.
  • new can take a class name from a property or chained expression.
  • Generated builtin listings and small support edits stay in sync.

JGamboa identified typed reference arguments and positional dynamic reference calls as known, pre-existing limitations to address separately. JGamboa also correctly disputed the earlier assembly-comment alignment finding; Greptile conceded it.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[PHP call or foreach target] --> B[Parser desugaring]
  B --> C[Runtime class or method dispatch]
  C --> D[Choose method and bind arguments]
  B --> E[Hidden foreach variables]
  E --> F[Store value, then key]
Loading

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

Comment thread src/parser/expr/dynamic_calls.rs
Comment thread src/ir_lower/expr/scoped_dynamic_calls.rs Outdated
Comment thread src/ir_lower/expr/scoped_dynamic_calls.rs
Comment thread src/parser/expr/dynamic_calls.rs Outdated
Comment thread src/ir_lower/expr/lsb_taint.rs
Comment thread tests/codegen/dyn_member.rs
Comment thread src/builtins/system/mod.rs Outdated
Comment thread src/codegen_support/runtime/callables/missing_argument_error.rs
@JGamboa
JGamboa force-pushed the fix/dynamic-members-calls branch from 8386099 to 8e8795a Compare October 21, 2026 01:51
Comment on lines +197 to +205
if let Some(variable) = place {
let element = Expr::new(
ExprKind::ArrayAccess {
array: Box::new(Expr::new(ExprKind::Variable(args_local.to_string()), span)),
index: Box::new(key),
},
span,
);
prelude.push(Stmt::new(StmtKind::RefAssign { target: variable, source: element }, span));

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 Calls break existing references

The new RefAssign binds each named variable to a fresh argument-array entry, even when the method takes that argument by value. This breaks references the caller already holds. After $n = 1; $alias =& $n; $o->$m(x: $n); $n = 2;, $alias stays 1 instead of becoming 2.

Passing the same variable to two reference parameters also gives them separate cells. Preserve the caller's existing storage instead of rebinding its variable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/parser/expr/dynamic_calls.rs
Line: 197-205

Comment:
**Calls break existing references**

The new `RefAssign` binds each named variable to a fresh argument-array entry, even when the method takes that argument by value. This breaks references the caller already holds. After `$n = 1; $alias =& $n; $o->$m(x: $n); $n = 2;`, `$alias` stays `1` instead of becoming `2`.

Passing the same variable to two reference parameters also gives them separate cells. Preserve the caller's existing storage instead of rebinding its variable.

---

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

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +79 to +82
// `$class::$p ?? d`: the runtime-named static property is read in PHP's silent probe mode.
if let Some(probed) = super::dynamic_class_access::lower_dynamic_static_property_probe(ctx, value) {
return probed;
}

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 Nested unset checks still throw

The new silent read only works when $class::$prop is the whole operand. With class C { public static array $p; } and $c = 'C', $c::$p['x'] ?? 'default' still reads the uninitialized property normally and throws instead of returning the default. isset($c::$p['x']) and empty($c::$p['x']) have the same gap.

Route the root property through the silent read when lowering a subscript chain.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ir_lower/expr/lazy_branches.rs
Line: 79-82

Comment:
**Nested unset checks still throw**

The new silent read only works when `$class::$prop` is the whole operand. With `class C { public static array $p; }` and `$c = 'C'`, `$c::$p['x'] ?? 'default'` still reads the uninitialized property normally and throws instead of returning the default. `isset($c::$p['x'])` and `empty($c::$p['x'])` have the same gap.

Route the root property through the silent read when lowering a subscript chain.

---

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

Fix in Claude Code Fix in Codex Fix in Cursor

…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.
…arguments, foreach places

- `self::$m()`, `static::{expr}()`, `parent::{expr}()`, `C::$m()` and `C::{expr}()` desugar to
  `__elephc_dynamic_static_call(<Receiver>::class, $name, ...)`, which
  `ir_lower::expr::scoped_dynamic_calls` expands into a method-name dispatch whose arms are
  the ordinary `Receiver::m(...)` calls: late static binding and `$this` forwarding are kept,
  visibility, arity and static context are checked per arm, and unknown names fall back to
  `__call`/`__callStatic`.
- `$obj->$m(..., name: v)` binds named arguments at run time through
  `call_user_func_array`; an unbound required parameter throws PHP's
  `<callee>(): Argument #N ($p) not passed`.
- `[$class, 'm']()` calls the callable array literal, and an untyped property holding a
  callable is called at run time.
- `foreach` key/value targets may be places (`$o->k`, `$a['k']`, `$o->{$p}`, `C::$x`,
  `$list[]`), assigned value first, then key.
…ors in runtime-named calls

- $o->$m(x: $n) desugars to call_user_func_array with a keyed argument
  array, whose plain value lost a by-reference parameter's write. A plain
  variable passed by name now aliases its array element, so the invoker
  writes back to it; by-value parameters still receive a copy.
- __call and __callStatic were removed from the candidates of
  C::$m() / self::$m() / static::$m(), so a runtime name that IS
  '__callStatic' went through the magic fallback with the name packed as
  an argument. They are ordinary candidates now.
- The self::$m(), static::$m() (including subclass-only declarers) and
  C::$m() arms raising ArgumentCountError for too few arguments now
  evaluate the supplied arguments first, as PHP does.
…wn at compile time

The dynamic named-argument desugaring re-implemented duplicate-name and
named/positional/spread ordering checks inside the parser. Those rules
now live in call_args::validate_unknown_callee_arg_order, next to
validate_no_spread_after_named, and the parser only maps the shared
errors to PHP's messages.
@JGamboa
JGamboa force-pushed the fix/dynamic-members-calls branch from 8e8795a to 15844af Compare October 21, 2026 06:04
Comment on lines +462 to +470
let undefined = string_concat_expr(
vec![
literal("Call to undefined method ", span),
Expr::new(ExprKind::Variable(name_local.clone()), span),
literal(&format!("::{method}()"), span),
],
span,
);
throw_error_and_merge(ctx, "Error", undefined, &result_temp, merge, span);

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 Runtime calls skip magic methods

For class C { static function __callStatic($name, $args) { return 'ok'; } }, calling $c::missing() through a runtime receiver throws instead of running C::__callStatic. The new dispatch only includes classes that declare the requested method, then throws on a miss. Inaccessible methods also throw without trying magic.

Apply the magic fallback after resolving the receiver's class.

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

Comment:
**Runtime calls skip magic methods**

For `class C { static function __callStatic($name, $args) { return 'ok'; } }`, calling `$c::missing()` through a runtime receiver throws instead of running `C::__callStatic`. The new dispatch only includes classes that declare the requested method, then throws on a miss. Inaccessible methods also throw without trying magic.

Apply the magic fallback after resolving the receiver's class.

---

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

Fix in Claude Code Fix in Codex Fix in Cursor

ctx.builder.position_at_end(on_this);
}
}
let args = arm_call_args(ctx, call_args, if has_spread { None } else { max_args }, span);

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 Subclass arguments disappear

static::$m(...) drops arguments using the enclosing class's parameter count before choosing the subclass method. If A::f($a) is overridden by B::f($a, $b = 0), a call inside A to static::$m(1, 2) cuts the arguments to [1], even when the called class is B. The subclass receives its default instead of 2.

Choose the called class's signature before trimming arguments.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ir_lower/expr/scoped_dynamic_calls.rs
Line: 263

Comment:
**Subclass arguments disappear**

`static::$m(...)` drops arguments using the enclosing class's parameter count before choosing the subclass method. If `A::f($a)` is overridden by `B::f($a, $b = 0)`, a call inside `A` to `static::$m(1, 2)` cuts the arguments to `[1]`, even when the called class is `B`. The subclass receives its default instead of `2`.

Choose the called class's signature before trimming arguments.

---

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

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/parser/control.rs
Comment on lines +417 to +420
ForeachTarget::Place(place) => Ok(Stmt::new(
crate::parser::stmt::assignment_target_store_stmt(place, source, span)?,
span,
)),

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 Loop writes disappear

foreach accepts an element target rooted in a runtime static-property read without rejecting the write. For example, foreach ([7] as $c::$items[0]) {} becomes a NestedArrayAssign whose parent is the value returned by __elephc_dynamic_static_property, not the static property's storage. The write changes a temporary and leaves the property unchanged.

Call reject_dynamic_member_write for place targets, as the append path already does.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/parser/control.rs
Line: 417-420

Comment:
**Loop writes disappear**

`foreach` accepts an element target rooted in a runtime static-property read without rejecting the write. For example, `foreach ([7] as $c::$items[0]) {}` becomes a `NestedArrayAssign` whose parent is the value returned by `__elephc_dynamic_static_property`, not the static property's storage. The write changes a temporary and leaves the property unchanged.

Call `reject_dynamic_member_write` for place targets, as the append path already does.

---

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

Fix in Claude Code Fix in Codex Fix in Cursor

Some(index.span),
);
}
coerce_string_offset_to_int(ctx, index_value, index.span)

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.

P2 String reads use old offsets

The new string branch keeps the old offset after a cast warning runs user code. A boxed float offset can be changed by set_error_handler; the existing string-read path reloads that variable afterward, but string_offset_index does not. With a mixed receiver containing 'abc', changing the offset from 1.9 to 2.9 in the handler still selects 'b' instead of 'c'.

Reuse the existing reload step for boxed variable offsets.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ir_lower/expr/array_access.rs
Line: 378

Comment:
**String reads use old offsets**

The new string branch keeps the old offset after a cast warning runs user code. A boxed float offset can be changed by `set_error_handler`; the existing string-read path reloads that variable afterward, but `string_offset_index` does not. With a `mixed` receiver containing `'abc'`, changing the offset from `1.9` to `2.9` in the handler still selects `'b'` instead of `'c'`.

Reuse the existing reload step for boxed variable offsets.

---

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

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:eir Touches EIR definitions, lowering, validation, or passes. area:parser Touches parsing or AST construction. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. 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

Development

Successfully merging this pull request may close these issues.

Support destructuring assignments in conditions and property foreach targets Support dynamic static method, property and constant member names

1 participant