Repository navigation
Conversation
[Medium impact] The PR changes compiler behavior for several PHP expression forms. Fix the magic fallback's argument checks before merging.
|
…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.
a84c884 to
7320d96
Compare
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.
… and __callStatic
| 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()), | ||
| }) |
There was a problem hiding this 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
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!
Summary
Class members reached through a runtime receiver, and the
newclass-name references PHP's grammar allows.Dynamic class member reads.
$class::NAME,$class::$prop,$obj::NAMEand$obj::$propfailed 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_accessexpands each one:get_class()for an object, the string minus one leading\for a class string, a runtime split formixed/union receivers);C::NAME/C::$propread;$this::NAMEisstatic::NAME, and$this::$propgoes through the runtime class.$obj::method()dispatches on the receiver's runtime class. It desugared tocall_user_func([$obj, 'm']), which failed at runtime with "callable array did not resolve to an invokable target". The new__elephc_dynamic_static_callprimitive matches the runtime class against every class that declares or inheritsm:mbecomesC::m(...), so late static binding sees the subclass;mfollows PHP's "call statically" rules;ArgumentCountError;Call to undefined method X::m().$cls::$method()(a variable method name) keeps thecall_user_funcpath.String offsets read from
mixed.f()[0]withf(): mixedreturning a string printed an empty value, because__rt_mixed_array_getonly understands containers. A boxed receiver that may hold a string now splits at runtime: a string payload takes the string-offset read (warnings preserved,??seesnullon a silent miss), and anything else keeps the container path. String-union receivers are covered too.newclass-name references. PHP'snew_variablegrammar, 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];new static::$classes[static::class]($m)(EloquentHasCollection).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-referenceforeach, destructuring into it;settype($c::$p['n'], ...)),unset().Docs:
docs/php/classes.md(dynamic instantiation and member access) anddocs/internals/the-parser.md. The three internal primitives get generated builtin docs pages (docs(builtins)commit, regenerated withscripts/docs/extract_builtins.py --render --force,gen_module_sections.pyandgen_php_comparison.py).Known limitation: when no class matches, PHP reports
Class "X" not foundfor an unknown class andUndefined constant X::NAME/Access to undeclared static property X::$pfor 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::$psite, so I left it out unless you prefer exactness here.Closes #850
Closes #1455
Part of #844 (the
$class::NAMEpart;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:main:
Fatal error: callable array did not resolve to an invokable target. php 8.5:FileLogger 1.0.A string offset read on a
mixedvalue is empty on main:main prints two empty lines. php 8.5:
3and1.Left out
call()[] = $v): 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); main's fix(parser): align compound append diagnostics with PHP #1700 already handles append expressions.$callback(...)first-class callables from runtime values: 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).::: the::keyword rule is in the include/require-as-expression PR (Allow literal include and require in nested and property-assignment expressions #852).$o::new()on an object receiver and$factory::DEFAULTneed both PRs. A test for the combination will come in a follow-up once the second of the two lands.;was already consumed has no test in either PR: its only real trigger is the refused writes of this PR, so that test comes in a follow-up once both land.Tests
This branch is based on
main@ df57a98. These tests fail on thatmainand 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_referencesnow uses a shape PHP's grammar also rejects (new $box->className::FOO), becausenew $box->classNameandnew $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);dynamic,oop::,array_access,string_offset,strict_phpandlate_static;validate_site_compat, the unit tests andaudit_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
mainwith its assistance. I reviewed the diff, and the regression tests were run againstmainand against this branch.🤖 Generated with Claude Code