Repository navigation
Conversation
[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.
|
8386099 to
8e8795a
Compare
| 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)); |
There was a problem hiding this 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.
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.| // `$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; | ||
| } |
There was a problem hiding this 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.
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.…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.
…d by-reference arguments
8e8795a to
15844af
Compare
| 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); |
There was a problem hiding this 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.
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.| ctx.builder.position_at_end(on_this); | ||
| } | ||
| } | ||
| let args = arm_call_args(ctx, call_args, if has_spread { None } else { max_args }, span); |
There was a problem hiding this comment.
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.| ForeachTarget::Place(place) => Ok(Stmt::new( | ||
| crate::parser::stmt::assignment_target_store_stmt(place, source, span)?, | ||
| span, | ||
| )), |
There was a problem hiding this comment.
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.| Some(index.span), | ||
| ); | ||
| } | ||
| coerce_string_offset_to_int(ctx, index_value, index.span) |
There was a problem hiding this comment.
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.
Depends on #1916 (dynamic class member reads and
$obj::m()dispatch): this branch is based on it and reuses its__elephc_dynamic_static_callprimitive 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}(...)andC::$name(...).They now desugar to
__elephc_dynamic_static_call(<Receiver>::class, $name, ...args). The::classmarker tells the lowering it is the scoped form.ir_lower::expr::scoped_dynamic_callsexpands it into a method-name dispatch whose arms are the ordinaryReceiver::m(...)calls:static::keeps late static binding, andself::/parent::forward it;$this, and a static context refuses it;Error/ArgumentCountErrormessages;__call/__callStaticfirst, then throwsCall to undefined method;static::when its body never observes the called class, or when the called class is that subclass. Otherwise it throws an explicitErrorrather than bindingstaticto the wrong class.self::$propwithout 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 becomescall_user_func_array([$obj, $name], $args)with the names as string keys, so they bind against the method resolved at run time:Named parameter $x overwrites previous argument;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).foreachplace targets.foreach ($m as $o->k => $o->v),as $a['k'] => $a['v'],as $o->{$p},as C::$xandas $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
foreachhalf; thewhilehalf 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
$obj->{$name}[] = v,$this[] = v/$obj[] = vthroughoffsetSet(null, v), appends in the middle of a write chain,$mixed[] = von scalars): covered by the append lvalues PR (fix(parser): accept append lvalues in the middle of writes, in expressions, on $this and on call results #1385).($this->cb)(...),$this(...),call()(...),$o->$m(...)): in the first-class callables PR (Parser rejects dynamic first-class callable syntax ($callable(...)) #828, Parser: support first-class callable syntax on callable-valued variables #864).$factory::new()(reserved words after a dynamic::): in the include/require-as-expression PR (Allow literal include and require in nested and property-assignment expressions #852).self::${$p},static::$$x): the implementation builds on the variable-variables desugaring, which waits for the variable-variables design decision (D6 in the review of feat: PHP compatibility batch (parser forms, checker convergence, references, dynamic includes, builtins) #1886), so it comes after that one.0.)): unrelated lexer fix, to be sent separately.Tests
This branch is based on the dynamic class access PR, which is based on
main@ df57a98. These tests fail on thatmainand 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(replacestest_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;dyn_member,dynamic,foreach,named,static_call,static_prop,strict_phpandcall_user_func;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
mainwith its assistance. I reviewed the diff, and the regression tests were run againstmainand against this branch.🤖 Generated with Claude Code