feat: Symfony L0 enablement — references, closures/magic methods, reflection, and parser gates#402
Draft
Guikingone wants to merge 65 commits into
Draft
Conversation
The include-path folder rejected any FunctionCall as runtime-dynamic, so the Symfony front-controller pattern 'require dirname(__DIR__) . "/vendor/autoload.php";' died at the resolver before autoload was reached. Add a dirname() arm to fold_include_path that folds dirname() of a compile-time-constant string when the optional levels argument is an integer literal >= 1. Move the dirname fold into a shared src/resolver/path_eval.rs module used by both the include-path folder and the autoload symbolic interpreter, fixing the interpreter's private Path::parent-based copy which diverged from PHP for '/', '.', and 'foo' (PHP returns '/', '.', '.'; Path::parent returned None). The shared fold_dirname matches PHP and the __rt_dirname runtime helper exactly, including trailing-slash stripping and internal-slash preservation. dirname() of a runtime value (variable, call result) is not folded and still emits the runtime helper; basename/realpath/pathinfo are not yet folded in include paths.
autoload.files helpers are always-included but frequently unreferenced by the app, yet one unsupported construct in an unused helper aborted the whole build: the prefix loop hard-failed on any load_autoloaded_file error. Change the prefix loop to skip the helper and record a CompileWarning naming the file and the failure reason, so an unreadable/unparseable/unresolvable unused helper no longer kills compilation. autoload::run now returns (Program, Vec<CompileWarning>) so the pipeline can report the collected warnings to stderr (mirroring the registry-warning path). The class-triggered loop keeps its hard ? : a class the program actually references must still load or the build fails -- tolerant-skip is only for unreferenced always-included helpers.
`$obj::class` / `$v[$i]::class` — `::class` on an expression receiver (PHP 8.0) — was rejected as "Expected method name after '::'"; only the named-receiver `Foo::class` / `self::class` / `static::class` forms parsed. Add a `Token::Class` branch to the postfix `::` arm in the Pratt parser that desugars `$expr::class` to `get_class($expr)`. For objects (the only case the Symfony static-bypass path exercises) this is PHP-faithful and reuses the existing get_class catalog entry, signature, and AArch64/x86_64 dynamic class-name lowering — no new AST node or runtime helper. Surfaces a separate, pre-existing gap (enum cases in array literals lose object identity, so `$v[$i]::class` on such an array returns "" — tracked independently, not this fix).
Lowering include/require as a bare statement only covered the direct
`$x = require F;` and `return require F;` shapes. Real PHP (and the
Composer/Symfony autoloader bootstrap) uses the value of an include deep
inside expressions:
if (true === (require_once $autoload) || false) { ... }
f(require $cfg);
echo require $val;
$x = require_once $cfg;
Add a deep-include hoister that walks every expression position
(comparison operands, call arguments, echo values, assignments, return
values, concatenation, etc.) and lifts a `require`/`include`[_once] that
always evaluates into a hidden temp capturing the included file's
top-level `return` (or `1` / `false`), then substitutes the temp for the
include expression. PHP source evaluation order and side effects are
preserved.
Includes that may be conditionally skipped or re-evaluated are rejected
with a diagnostic instead of miscompiled: short-circuit operands
(`&&`/`||`/`??`), ternary branches, nullsafe chains, match arms, and
loop conditions (`while`/`do-while`/`for`). Direct `if (...)`/`else`
conditions still run once and remain supported.
The hoister reuses the existing inlining + return-capture machinery in
the resolver; the parser already emitted `ExprKind::IncludeValue` for
these forms, which the resolver now expands in every position instead
of only the two direct cases.
`eliminate_dead_code` tail-sinking moved every statement following an
`if`/`ifdef`/`switch`/`try` into each fallthrough branch. When the tail
contained a declaration (`function`, `class`, `const`, ...), the symbol
was emitted once per branch and linking failed with a duplicate
`_fn_<name>` / class symbol. This shape is common in real code, e.g. a
function declared after a guarded block:
if ($cond) { ... } else { ... }
function helper(int $n): int { return $n * 2; }
echo helper(3);
Declarations are hoisted by lowering and carry no runtime guard, so
sinking them into branches only ever duplicates them. Partition the tail
into `(sinkable, declarations)`: only non-declaration statements sink
into the branches; pure symbol declarations are kept once at the outer
level (DCE'd in place) after the rewritten control-flow statement.
Limited to declarations whose symbol is purely hoisted (function, class,
enum, interface, trait, packed class, const, extern fn/class/global).
Statements with runtime or ordering semantics — `include_once` guards,
injected `Synthetic` blocks, namespace blocks, `use` aliases — stay in
the sunk tail so their position relative to surrounding control flow is
preserved.
Regression tests: a unit test asserting the declaration is emitted once
and never duplicated into branches, and end-to-end tests for a function
and a class declared after an `if`/`else` that previously failed to
link.
WeakMap is an object-keyed map modeled on SplObjectStorage: parallel protected arrays of object keys and values plus an iterator cursor, implementing Countable, ArrayAccess, and Iterator. Object identity uses strict ===, and foreach yields each object key with its mapped value. elephc has no per-object finalizer or weak-reference registry, so this is a strong map: stored keys keep their objects alive. While a key is live, get/set/count/isset/unset and iteration match PHP; auto-eviction when a key object is freed is a documented gap (docs/php/spl.md), as is offsetGet returning null instead of throwing on an absent key. Wiring is additive: a new weak_map.rs checker module, a registry entry for the redeclaration guard, EIR support-list arms, and a dynamic-new AOT candidate. Tests cover set/get, count, isset/unset, foreach, the absent-key null return, and redeclaration rejection.
…_isset/__unset Three related PHP capabilities: - __callStatic, __isset, and __unset magic methods, completing the property/method interception set alongside __get/__set/__call. isset() and unset() on an undeclared property dispatch to __isset/__unset, and an undefined static call dispatches to __callStatic. - $this auto-binding in non-static closures and arrow functions defined inside an instance method. Previously $this was null inside such closures at runtime (a silent miscompile); it now flows through the capture machinery, including into nested closures. - Closure::bind and $closure->bindTo($newThis [, $scope]) for closures that capture $this, via a new __rt_closure_bind runtime helper that copies the closure descriptor and rebinds its receiver. Closures with other captures abort with a clear fatal; $closure->call() and binding top-level closures are not yet supported. Includes checker, EIR lowering, runtime, codegen tests, examples, and docs.
- $closure->call($newThis, ...$args) binds $this and invokes the closure in one step, returning its result (lowered as a rebind followed by a dynamic descriptor invoke). - A non-static closure or arrow function may now reference $this even when defined outside a class method; it is bound to an object later via Closure::bind / bindTo. Such a closure carries a null, Mixed-typed $this capture that the bind fills, and member access dispatches at runtime against the bound object's class — including the canonical scope-stealing pattern that reaches private members. The checker tracks closure nesting (closure_depth) so $this is permitted inside a closure body but still rejected in plain top-level code. __rt_closure_bind boxes the receiver into a Mixed cell for a Mixed ($this) capture and stores it raw for an object capture, selected by the capture's runtime type tag. Verified on macOS arm64, Linux arm64, and Linux x86_64.
new ReflectionFunction('fn') reflects a user-defined function:
getName(), getShortName(), getNumberOfParameters(), and
getNumberOfRequiredParameters(). Metadata is baked at compile time from the
function's lowered signature (param count, required count via defaults, variadic
handling). The constructor validates the function exists (checked against
fn_decls, case-insensitively).
Adds the builtin ReflectionFunction shell, constructor recognition/validation,
EIR codegen (slot population read from property_offsets), required-method
collection, and a codegen test.
ReflectionFunction::getParameters() returns an array of ReflectionParameter objects, each exposing getName(), getPosition(), isOptional(), and isVariadic(). The array of parameter objects is built at construction from the function's lowered signature (a parameter is optional once a default or the variadic is seen, matching PHP). ReflectionParameter (and ReflectionFunction) are seeded as materializable reflection classes so their vtables are emitted even though they are only constructed internally, not via a PHP `new`.
getType()/hasType() now expose a parameter's declared type as a ReflectionNamedType (getName/isBuiltin/allowsNull). The type object is stored as a properly boxed Mixed cell in the mixed __type slot so the ?ReflectionNamedType receiver dispatches through the Mixed unbox path; storing a raw pointer + tag previously made getType()->getName() crash. Untyped parameters report hasType()==false / getType()==null: the EIR signature represents both untyped and explicit-mixed params as Mixed and declared_params is unreliable at codegen (it is also set for boxed-ABI params), so a bare Mixed maps to no named type. Nullable (?T) and class-typed params are handled; explicit mixed is reported as untyped.
Attribute arguments may now be float literals (and negated numeric literals), surfaced through ReflectionClass/Method/Property::getAttributes() ->getArguments(), class_attribute_args(), and ReflectionAttribute:: newInstance(). Float is stored as the IEEE-754 bit pattern in AttrArgValue so the enum keeps deriving Eq/Hash/Ord. This also reshapes captured attribute args into keyed AttrArgEntry records (AttrArgValue + optional AttrKey) so the upcoming named-argument and array support can attach keys; named args and arrays remain reported as unsupported metadata until their materializers land.
Positional array literals (including nested arrays and heterogeneous element types) are now materialized as attribute arguments, surfaced through ReflectionClass/Method/Property::getAttributes()->getArguments(), class_attribute_args(), and ReflectionAttribute::newInstance(). The active EIR materializer builds the nested array recursively via the existing indexed-array machinery and boxes it with the array tag. The legacy AST backend and the dead runtime flat-args table (which no runtime routine reads) degrade array arguments to a null placeholder; the active EIR path is the real implementation. Associative arrays and named arguments remain reported as unsupported metadata until the keyed hash-array materializer lands.
ReflectionClass/Method/Property::getAttributes()->getArguments() now returns named arguments under their string keys and associative-array arguments with keys preserved, matching PHP; newInstance() constructs the attribute with them. Rather than emit a bespoke keyed-hash array in codegen, getArguments() is lowered as a synthetic dispatch (on the attribute factory id) that returns the captured arguments as a normal associative array literal, reusing the existing array lowering on every target. The factory registry is extended to cover non-class attributes (a resolvable flag) so every attribute dispatches correctly; newInstance() skips the non-class ones. getArguments() now returns an associative array (mixed keys). The flat class_attribute_args() helper cannot represent keys, so it rejects attributes whose arguments are keyed at any depth and directs callers to ReflectionClass::getAttributes(). Constant/enum references remain unsupported.
Resolve symbolic constant references in PHP attribute arguments through reflection: a global constant (#[A(SOME_CONST)]) or a class/interface constant (#[A(C::BAR)]). getArguments() returns the resolved values and newInstance() constructs the attribute with them. Captured at schema time as deferred AttrArgValue::ConstRef/ScopedConst variants (values are not known then) and re-emitted as the corresponding AST reference nodes in the synthetic getArguments()/newInstance() bodies, so the normal const-resolution lowering materializes them on every target. A checker post-pass drops references the EIR backend cannot lower (e.g. the built-in Attribute::TARGET_CLASS), preserving the prior compiles-but- not-reflectable behavior. Enum-case arguments are deferred the same way: they compile and the attribute name stays reflectable, but materializing the case object through a Mixed hash has a linux-x86_64 ownership bug to fix first. Also fixes the associative-array value-type stamp for a ScopedConstantAccess element, which used the syntactic ::class-is-string default and corrupted int/float class-constant reads.
A string-typed property read off an object retrieved from a Mixed-valued hash ($mixedHash[k]->strprop) heap-exhausted on linux-x86_64: the string-pointer result register aliased the object base register in emit_property_load, so the length word was read from the string payload (a garbage length that drove __rt_str_persist to exhaust the heap). Upstream fixed the codegen in 8b79568; this adds the regression test that guards it — a plain object reproduces it, so it is not enum- or attribute-specific.
#[A(E::Case)] now materializes the enum case object through reflection, matching PHP's ReflectionAttribute::getArguments() and newInstance(). The captured ScopedConst reference re-emits as an enum-case access that lowers to the case object and is boxed as Mixed in the arguments hash; the checker now marks enum-case references materializable, so they are no longer dropped to compiles-but-not-reflectable. This was the last deferred piece of non-literal attribute arguments. It was blocked by the x86_64 string-property aliasing bug fixed in the previous commit (reading the case object's ->value triggered the runaway allocation); with that fixed, enum-case arguments work on all three supported targets.
… return bool empty($obj->prop) on an overloaded (undeclared) property fell through to the eager empty builtin, which evaluated __get and checked only its truthiness — so an unset virtual property whose __get would return a truthy value was reported non-empty. It now consults __isset first (PHP semantics) and reads __get only when __isset is truthy, matching the documented contract in docs/php/classes.md. isset() and empty() now evaluate to bool instead of int, so var_dump renders bool(...) and `echo` of a false result yields "" (not "0"), matching PHP. Tests that encoded the old int-echo behavior are updated to the PHP-correct output.
Adds __rt_hash_unset runtime (aarch64 + x86_64) and a HashUnset EIR op so unset() can remove a single entry from an associative array. The helper copy-on-write splits the table, releases the removed entry's owned key/value payloads, tombstones the slot (occupied=2, preserving probe chains), unlinks it from the insertion-order chain, and decrements the live count; a missing key or null/empty table is a no-op. Lowering accepts plain-variable hash receivers in unset_target_supported and emits HashUnset, storing the returned (possibly cloned) table pointer back to the local. Object ArrayAccess receivers still dispatch to offsetUnset(). 3-target green (macOS arm64, Linux x86_64, Linux arm64): 7 regression tests covering string/int keys, tombstone+reinsert order, copy-on-write isolation, heap-value release, leak-under-churn, and missing-key no-op.
PHP's unset() removes an array key without renumbering the survivors, so a packed indexed array becomes sparse (a hole). Two coordinated changes make this work: - Checker: when an element of a packed Array<T> local is unset, promote the local to AssocArray<Int,T> so its literal is built as a hash (top-level scope, where the checker's env reaches lowering). - EIR lowering: convert-on-unset. When unset($arr[$k]) targets a packed Array local (function locals and by-value params, where the runtime value is still packed), emit Op::ArrayToHash, retype the local to AssocArray, and remove the element via HashUnset. This is copy-on-write correct (the source array is not mutated) and covers every non-by-ref scope. By-reference array params are excluded (converting a caller-aliased array would desync the caller's static type) and report a clear compile error. 3-target green (macOS arm64 full suite 4085/0; Linux x86_64 + arm64 unset filter 56/0). 6 regression tests: hole creation, append-continues-max-key, function-local, by-value param, copy-on-write, empty-array no-op.
Allow array_map and the other callback builtins to run over a boxed-mixed indexed array, and fix closure return-type inference so a mixed/untyped parameter passed straight through (return $x) infers a mixed return type instead of coercing the value to int. - codegen_ir: accept Mixed array elements in array_map's element-type gate - ir_lower: direct_closure_return_expr_type consults parameters, so a bare return $param adopts the parameter's declared type (e.g. mixed) rather than the syntactic integer default The untyped-closure-param checker limitation (param locks to the first call type) is separate and still open. 6 regression tests + example + docs.
An untyped closure parameter monomorphized to its first call site's type: a
second call with a different type was rejected ("expects Str, got Int"), and a
string argument passed through `return $param` was miscompiled to 0 (the closure
was typed `-> I64` and cast the boxed Mixed argument to an integer).
Three coordinated type-inference fixes (no codegen or runtime changes):
- ir_lower: a bare `return $param` adopts the parameter's Mixed signature type
rather than the syntactic Int default, so a pass-through closure is no longer
typed `-> I64`.
- checker (specialize_callable_var_sig_from_args): mirror the regular-function
specialization protocol so a closure variable invoked with differing types
widens to the union (Mixed) instead of locking and rejecting later calls.
- checker (resolve_closure_return_type): force the inferred return of a closure
whose body returns a bare untyped parameter to Mixed, so a function returning
the closure result is not coerced to int.
Untyped parameter bodies are still checked with the Int monomorphic hint, so a
closure with a declared return type doing arithmetic on the parameter is
unaffected. Adds codegen tests, an example, and docs.
`foreach ($arr as [pattern])` and `foreach ($arr as $k => [pattern])` now desugar at parse time to a Foreach whose value_var is a synthetic temp, with the list-unpack statement prepended to the body. Reuses the existing lower_list_unpack path (positional, keyed, nested, holes) so foreach destructuring stays in lockstep with standalone bracket/list destructuring and touches no AST-walking passes. By-reference pattern targets remain unsupported and bind by value, matching the documented standalone-destructuring limitation. Adds the parse_and_lower_foreach_destructure entry point in the assign list module, two destructure branches in parse_foreach, and one helper. Tests: 7 codegen, 3 parser, 3 error. Example + docs included.
Add the PHP "clone" expression across the full pipeline: lexer token, Pratt prefix parse (BP 38, tighter than "**"), type checker (rejects definite non-object operands), EIR lowering via Op::ObjectClone, and target-aware EIR codegen calling __rt_object_clone. The runtime helper shallow-copies the object payload, then walks the gc descriptor to persist owned strings (tag 1) and incref refcounted children (tags 4/5/6/7), preserving the source's packed heap kind word instead of stamping a bare kind byte. Mixed-boxed operands (the Symfony DeepClone path, where prototype arrays are untyped) rebox the inner object via the source's kind word too. A class_id-indexed _class_clone_ptrs table dispatches __clone() on the fresh copy when declared (inheritance pre-resolved), mirroring _class_destruct_ptrs. x86_64 lowering uses RIP-relative symbol refs (emit_cmp_reg_to_symbol / emit_symbol_address) for PIE-safe table access, the 2-operand "and" form, and a 16-byte-aligned stack frame. ARM64 masks the kind word with #0xff before the object-kind comparison. Tests: lexer, 4 parser (incl. precedence vs "**" and postfix operand binding), 3 error (non-object operand, static __clone, __clone with args), 8 codegen (shallow independence, __clone once, inherited __clone, shared nested object, dynamic properties, inside function, mixed-boxed object, GC-clean under 2000x churn). Example and docs/php/classes.md section added. 3-target green.
Parse the PHP new_variable dereference chain after 'new $var' — array offsets (new $arr['k'](...)), property reads (new $obj->prop(...)), and nested combinations — plus the PHP 8.0 parenthesized form new (expr)(...). The trailing (...) is always taken as the constructor argument list, never as a call on the class-name expression. This is the first wall in Symfony's runtime bootstrap (autoload_runtime.php: new $_SERVER['APP_RUNTIME'](...)). The downstream pipeline (checker, closed-world class collection, EIR lowering) already treats NewDynamic.name_expr as an arbitrary expression, so this is a parser-only change. Tests: 4 parser, 4 codegen, 2 error. Example: examples/dynamic-new. Docs: docs/php/classes.md dynamic-instantiation section.
Parse the array(...) language construct as an array literal equivalent to
the short [...] form, instead of as a call to a function named 'array'.
Previously array(1, 2) failed with "Undefined function: array" and any
key => value entry failed with "Expected ',' between arguments".
The 'array' keyword (case-insensitive) followed by '(' is intercepted in
the prefix parser and routed to the array-literal entry parser, which now
takes a parameterized closing delimiter shared by both the short [...] and
long array(...) forms. Positional elements, key => value entries, nesting,
and ... spreads all behave identically across the two forms.
Next Symfony L0 wall after this: composer/ClassLoader.php uses array()
pervasively (array($this->vendorDir => $this)).
Tests: 4 parser, 9 codegen, 1 error. Example: examples/long-array-syntax.
Docs: docs/php/arrays.md long-form array() section.
PHP binds `=` to the lvalue immediately to its left even when the assignment is the operand of a higher-precedence operator: `false !== $x = f()` parses as `false !== ($x = f())`, `1 + $b = 5` as `1 + ($b = 5)`, and `!$b = 7` as `!($b = 7)`. elephc previously rejected these with "Invalid assignment target". Fix in the Pratt assignment loop: consume the assignment when the current lhs is a valid target even if the assignment's binding power is below min_bp; only yield to the enclosing context (break) when lhs is not an lvalue, which then surfaces as the invalid-target error. The change is purely additive — it only accepts programs that previously errored and cannot alter the parse of any program that already compiled. Right-assoc chaining and the low assignment right-binding-power (ternary capture) are preserved. Semantics cross-checked with php -r across +, *, &&, !==, and prefix !. Next Symfony L0 wall: composer/ClassLoader.php:500,522 cleared. Tests: 3 parser, 5 codegen, 1 error. Example: examples/assignment-in-condition. Docs: docs/php/operators.md assignment-precedence section.
substr lowered to pointer arithmetic that returned a borrowed slice into the source string's buffer. When the result was stored back into the same variable inside a loop and the variable was re-read afterward (e.g. $s = substr($s, 0, $p); strrpos($s, ...)), the store released the old buffer the slice still pointed into, so the next read saw a freed/reused region — the first bytes turned to garbage and the loop walked the wrong data. Fix: lower_substr now persists the slice to an owned heap copy (__rt_str_persist) before storing, matching PHP's value semantics, and substr is registered as an owning temporary so the extra copy is released and allocs/frees stay balanced (no leak, no double-free). Reproduces with or without an assignment-in-condition; independent of the recent parser gates. Found while probing Composer's ClassLoader namespace walk. strstr/strrchr/strchr return slices the same way and remain latent (separate follow-up). Tests: 2 codegen (corruption + repeated self-reassign), 1 runtime_gc (alloc/free balance under loop churn). 3-target green.
PHP allows [$a, $b] = EXPR as an expression (e.g. if ([$a, $b] = $row)),
which binds the targets and evaluates to EXPR. elephc only supported the
statement form; in expression position the Pratt assignment loop rejected
the array-literal target with "Invalid assignment target".
Add ExprKind::ListUnpack { vars, value } — the expression-position twin of
StmtKind::ListUnpack — for the simple all-positional-$variable case. The
parser builds it when a plain `=` has an all-simple-variable array-literal
target (keyed/nested forms stay statement-only and still error in
expression position). EIR lowering mirrors the statement form (evaluate the
source once, assign each element positionally) and yields the source value;
the checker binds the targets via the assignment-effects path and types the
expression as the RHS type. Following the PHP 8.5 pipe-operator precedent,
arms were added across every expression-visiting pass.
Clears the first compile wall in Symfony's polyfill-deepclone DeepClone.php
(line 492, `if ([$scopeName, $realName] = $propertyScopes[$name] ?? null)`).
Tests: 2 parser, 3 codegen, 1 error. Example: examples/array-destructuring.
Docs: docs/php/arrays.md destructuring section.
`$a[$k] ?? $default` lowered to an eager element read followed by an IsNull check. For a missing key on an associative array whose values are arrays, the read (HashGet/ArrayGet) returns a garbage descriptor typed as an array — there is no null representation for a statically array-typed element — so the result was used as a real array and crashed (SIGSEGV on var_dump / concat). String-valued elements wrongly yielded "" instead of null. Fix: `??` on an ArrayAccess now uses PHP isset semantics. A synthetic `isset($a[$k])` guards the read (false for a missing key OR a null value, exactly the `??` condition, and already correct for plain arrays, hashes, string offsets, and ArrayAccess objects); the element is read only when present, otherwise the default is taken. The result type is widened to Mixed when the element and default types differ so a null/scalar default is representable alongside an array/object element. Note: array_access_expr_satisfies_array_access only matches ArrayAccess *objects*, so the guard must key off `value.kind == ArrayAccess`, not that predicate. This also makes `$propertyScopes[$name] ?? null` (Symfony DeepClone.php:492) run correctly alongside the list-unpack gate. Tests: 4 codegen regressions (array-valued/string-valued/nested missing keys). 3-target green; broad ?? regression sweep clean.
Completes the M3 reference work for illegalstudio#80 by accepting `$r =& $a[0]`, the reverse of `$a[0] =& $r`: the variable aliases the array element, so both observe writes through either side. $a = [1, 2]; $r =& $a[0]; $a[0] = 9; echo $r; // 9 The element-source form is lowered to a synthetic pair `$r = $a[0]; $a[0] =& $r;` — copy the element value into the variable, then alias the element to the variable's reference cell. Because the source lvalue is re-evaluated, a source whose subscript has side effects (a call, etc.) is rejected with a clear diagnostic rather than evaluated twice. A bare variable source still lowers to the existing local-to-local `RefAssign`. Property-element sources (`$r =& $o->p`) parse but remain gated by the existing property-target diagnostic until the object-property milestone. Tests: two codegen tests (alias both directions) and an error test for the side-effecting-source gate; the parser, error, and reference suites pass on all three targets.
Implements the DeepClone reference pattern for illegalstudio#80: a foreach-by-reference value can be the source of a reference assignment into another array element. $vars = ['a' => 1, 'b' => 'x']; $scoped = []; foreach ($vars as $name => &$value) { $scoped[$name] =& $value; // share the original entry, not a copy } $scoped['a'] = 99; echo $vars['a']; // 99 — the write reaches the source array A foreach-by-reference value binds an interior pointer into the iterated hash entry's value triple. Aliasing it promotes that entry in place into a shared reference cell (new runtime helper `__rt_promote_entry_to_refcell`): the entry's value moves into a fresh heap-kind-6 cell and the entry becomes a tag-11 reference to it, so the source array and the assignment target share one cell. The cell is co-owned (origin entry plus each target), so it is increffed per new owner. Re-aliasing an already-promoted entry reuses the same cell. The hash-iterator value-ref binding now marks its slot so codegen can tell a foreach interior pointer (entry layout `[lo][hi][tag]`) from a value source and route it to the entry-promotion path instead of value boxing. Verified on all three targets: the reference suite (including a new foreach by-ref sharing test) passes. Known follow-ups: write-through still does not reach the origin for a *nested* target (`$scoped[$s][$k] =& $value`) because nested Mixed element writes go through a deopt runtime path not yet taught the tag-11 write-through (a pre-existing limitation shared with nested plain references); and reference-cell teardown for the bounded leak is deferred to the teardown milestone.
Implements `$obj->prop =& $v` for a stdClass receiver (M5), the object-property form of reference-into-storage. The reference rides the object's dynamic-property hash: a new `RefAssignProperty` EIR op promotes the source local into a shared heap-kind-6 reference cell and stores it under the property name with per-entry value_tag 11 via a new `__rt_stdclass_set_ref` runtime helper (lazy hash alloc + `__rt_hash_set` + write-back to obj+8, mirroring `__rt_stdclass_set`). Property reads dereference the reference for free through the existing `__rt_stdclass_get` -> `__rt_hash_get` tag-11 path. Writing the aliased property (`$obj->prop = 9`) now propagates back to the source: `__rt_refcell_store` unboxes a boxed-Mixed (tag 7) new value before write-through, so a typed reader reconstructs the scalar instead of seeing a box pointer (the property write path boxes its value, unlike the array-element path). The checker accepts only a statically-typed stdClass receiver; declared typed-class properties (packed fields) and Mixed receivers (boxed pointers the hash codegen cannot dereference) stay gated with the existing diagnostic. 3 codegen tests (share, write-through, two independent properties) + 2 error tests (declared-class and Mixed-receiver gated). 3-target green: macOS reference 42 / runtime_gc 97 (+ oop/json/closures/foreach/arrays/strings), Docker x86_64 and arm64 reference 42 / runtime_gc 97.
Completes the reference-assignment-into-storage feature (M6). Investigation showed the heap-kind-6 reference cell is already reclaimed exactly once — it is owned by its container (the array/property hash) and freed through __rt_hash_free_deep's tag-11 dispatch when the container is released, while the aliased source variable borrows it — so no source-variable teardown change was needed and the feature is leak-neutral relative to its baseline. - GC/leak suite (tests/codegen/runtime_gc/references.rs): tight 100k-iteration loops for a single element reference, two independent references in one array, and a source surviving its container's unset (each would crash on a leak or double-free), plus a copy-on-write split that keeps the reference shared. - Docs: a "References to array elements and object properties" section in docs/php/arrays.md and a "Reference cells (heap kind 6)" section in docs/internals/the-runtime.md (cell layout, the six refcell helpers, __rt_stdclass_set_ref, and ownership). - Example: examples/reference-into-array. Known limitations (documented; shared with the array-element path, not new): re-reading a variable in the same scope after writing through its alias on the other side, and unset() of a referenced source, may not reflect the latest value; string and float sources, references into declared/typed-object properties, and reference returns stay unsupported. 3-target green: macOS runtime_gc 101 (incl. 4 new), Docker x86_64 and arm64 references 4/4.
Constant propagation substituted a variable's last directly-assigned scalar constant even after the variable was bound by reference, so a write through one of its aliases produced a stale read. For example `$a['x'] =& $v; $v = 5; $a['x'] = 9; echo $v;` printed 5 instead of 9 — the `echo $v` was folded to the constant 5, ignoring the write-through. The same affected a local `$a =& $b` and a `foreach (... as &$value)` binding. A reference binding means a write through any alias can change the value invisibly to per-variable tracking, so such variables must never be propagated. The propagation pass now records every reference-escaped variable — the source of `$arr[$k] =& $v` / `$obj->p =& $v`, both names of a local `$a =& $b`, and a `foreach (... as &$value)` value variable — in a per-run set, and neither substitutes nor re-records a tracked constant for those names. 3 regression tests (array element, local reference, foreach-by-ref). 3-target green: optimizer 201 and reference 48 on macOS, Docker x86_64, and arm64; no change to the existing optimizer/reference/oop/arrays/strings suites.
…he cell Reading a stdClass dynamic property that holds a reference entry (`$snap = $obj->prop` while `$obj->prop =& $v`) returned a live alias to the shared reference cell rather than a value snapshot: `__rt_stdclass_get` boxed the stored `value_lo` directly, which for a tag-11 entry is the reference-cell pointer, so a later write through the reference changed the already-read value (`$o->p =& $v; $first = $o->p; $v = 9; echo $first;` printed 9 instead of 1). `__rt_stdclass_get` now checks the per-entry value tag: a tag-11 reference entry is dereferenced with `__rt_refcell_load` and boxed into a fresh Mixed snapshot (matching the array-element read path), so the read observes the current value and stays independent of later writes through the reference. Non-reference entries keep returning the retained boxed Mixed value. Regression test for the snapshot; 3-target green: macOS oop 410 / json 420 / reference 48, Docker x86_64 and arm64 stdclass 23 and reference suites.
…torage
A nested element write on a literal heterogeneous base silently lost the write:
`$m = ['a' => ['x' => 1], 'b' => 2]; $m['a']['x'] = 99;` printed
`{"a":{"x":1},"b":2}`, while the same on a `json_decode()` base worked. Two
representational gaps combined, both fixed here:
1. Store side: a heap container (`Array`/`AssocArray`/`Object`) stored into a
Mixed-valued hash entry was kept with its natural heap tag (4/5/6). On
read-back `__rt_mixed_from_value` increfs the shared inner container, raising
its refcount above one, so a later in-place `__rt_hash_set` copy-on-writes
into a detached clone and the nested write is lost. `json_decode()` instead
stores a boxed Mixed (tag 7) that uniquely owns the container (refcount 1),
so no COW happens. New `mixed_storage_boxes_container` helper boxes such
containers into a Mixed cell at store time on both targets, matching the
`json_decode()` representation.
2. Write side: `lower_nested_array_assign` lowered the whole target as an
rvalue, reading the leaf through `__rt_mixed_array_get` — which re-boxes a
copy for a non-tag-7 entry — then assigned into that copy. It now splits the
outermost `[$key]` off and, when the parent reads as Mixed/Union, lowers the
write as `__rt_mixed_array_set(parent, key, value)`, which unboxes the parent
to the shared inner hash and writes the entry in place.
GC stays balanced (allocs == frees); the only effect is one extra boxed cell per
container store. Updated `test_cow_hash_assignment_detaches_before_forming_cycle`
to the new +2 delta and to assert allocs == frees, plus two regression tests for
the literal nested write and its read-only counterpart. A precise-typed
homogeneous inner array (e.g. `['a' => ['x' => 1, 'y' => 2]]`) remains a clean
compile-time gate, not a silent miscompile. 3-target green: macOS, Docker
x86_64, Docker arm64 (cow_and_cycles 21, runtime_gc 101, the new tests).
`++$obj->prop;`, `--$arr[$k];` and the indexed form previously failed to
parse ("Expected ';'") because the statement dispatcher only accepted a
bare variable after a leading `++`/`--`. In statement position the result
is discarded, so a prefix increment is observably identical to the postfix
form and lowers to the same `$target += 1` read-modify-write that
`$target++;` already used.
Adds `try_parse_prefix_incdec`, tried before `parse_incdec_stmt`: a bare
`++$x;` still keeps its `PreIncrement` node, while a complex l-value routes
through the existing `lower_postfix_incdec_assignment`. Expression-position
increment on a complex l-value stays unsupported (symmetric with postfix).
Clears the Symfony polyfill-deepclone `++$value->count;` compile wall.
Adds `extension_loaded(string $extension): bool`, previously an undefined
function. In the closed-world AOT model the loaded-extension set is fixed at
compile time, so the call lowers to a static boolean. elephc reports no
dynamically loaded PHP extensions (the set is conservatively empty), matching
extension names case-insensitively like PHP.
This is the correct value for `if (!extension_loaded('x'))` polyfill guards:
the userland fallback path is selected, while elephc-provided functions remain
visible through function_exists() and the builtin catalog.
Wired through the catalog, signatures (call + first-class-callable), the system
checker, EIR return-type override, and the EIR backend lowering. Adds codegen,
error, and case-insensitivity tests plus system-and-io docs.
PHP polyfills guard their userland fallbacks with
`if (!function_exists('X')) { function X(...) { ... } }`. When the wrapper body
delegates to a class (e.g. symfony/polyfill-deepclone's 97 KB `DeepClone`), the
closed-world autoload pass pulls that class into the compile even though nothing
calls the function on the reachable path.
Adds an autoload-stage AST transform that removes these guards for an explicit
allowlist of functions elephc declares to provide — the PHP 8.5 deepclone
surface (deepclone_to_array/from_array/hydrate). The transform runs before the
autoload reference graph is collected, so the delegated classes never enter the
compile. It is a purely structural rewrite over `if`/`!`/
`function_exists('literal')`, recurses into nested bodies, leaves unrelated
guards and any guard with `elseif` clauses untouched, and matches the function
name case-insensitively through a leading namespace separator.
Combined with the extension_loaded() builtin, this advances the Symfony L0
compile probe entirely past polyfill-deepclone.
Unit tests cover the transform (provided/unprovided/case/namespace/nested), and
two autoload e2e tests prove the guard is pruned before a broken delegate class
is loaded (and that an unprovided guard still loads it). Documents the behavior
in the compilation-pipeline page.
extension_loaded() lowers to a compile-time constant and has no runtime
`_fn_extension_loaded` symbol, exactly like function_exists(). Listing it among
the first-class-callable builtins made the dynamic-callable dispatch table emit a
wrapper for it — the table emits one wrapper per FCC-eligible builtin — so every
program that uses any dynamic string callback (e.g. array_map("inc", ...))
referenced the missing `_fn_extension_loaded` symbol and failed to link.
Drop extension_loaded from first_class_callable_builtin_sig, matching the
function_exists precedent. Regression introduced when the builtin was added.
Adds a regression test compiling a program that both calls extension_loaded and
invokes a user function through a dynamic string callback.
`++$obj->p`, `$obj->p++`, `++$a[$i]`, `$a[$i]--` (including `$this->p`) now work as expressions, not only as discarded statements. A prefix form yields the new value and a postfix form yields the old value, matching PHP. These desugar to the existing compound-assignment machinery — prefix `++L` becomes `(L += 1)`, postfix `L++` becomes `(L += 1) - 1` — so the target's receiver and index are evaluated exactly once and no new ExprKind or IR is needed. Bare variables keep their dedicated `PreIncrement`/`PostIncrement` nodes. A shared `build_assignment_expression` helper is extracted from the assignment operator branch so both paths apply identical target stabilization. Also fixes two statement-position gaps surfaced along the way: `++$this->p;` failed because `$this` is a distinct token that the complex-l-value statement handler did not accept, and `$x = $o->n++;` was mis-parsed because the postfix increment statement handler greedily treated the whole left side as the target. Clears the Symfony L0 keystone walls (http-kernel/Kernel and Twig Lexer/TokenStream/ArrayExpression/Parser/Compiler).
PHP allows any expression as a statement. The statement dispatcher only routed variable-, keyword-, and identifier-led statements to a parser; a statement beginning with a literal, comparison, unary operator, `new`, or `clone` fell through to an "Unexpected token at statement position" error. This rejected the short-circuit guard idiom `0 > $t && $t += 0x40;` (used by Symfony's intl-normalizer polyfill) and bare `new C();`. Add an authoritative `token_starts_prefix_expression` predicate, kept in lockstep with `parse_prefix`, and a dispatcher fallback that parses such statements as `expr ;`. Purely additive: tokens with dedicated arms never reach the fallback, and tokens that cannot begin an expression keep the existing "statement position" diagnostic. Variable-led non-assignment statements (`$ok && f();`) go through a separate path and remain a distinct gap.
…e to runtime fatals elephc is closed-world, so a runtime-dynamic include/require path (e.g. `require $file;`) is rejected at compile time. That strictness blocked compiling real libraries whose autoloaded class files contain a lazy dynamic include — e.g. Symfony's intl-normalizer polyfill `require`s a Unicode data table by a computed path inside `getData()`, a method a simple page never calls. When the resolver runs on an autoloader-spliced class file, an unresolvable runtime-dynamic include is now lowered to a diverging runtime-fatal stub (`fwrite(STDERR, "... <path>"); exit(255)`) instead of a hard error: the class compiles, and the stub fires only if that branch actually runs. The message concatenates the original path expression, so the diagnostic names the real path and the path variable is not left "unused". `exit` is recognized as diverging by termination analysis, so a value-position `return require $dynamic;` needs no fall-through return. The main program and eagerly-executed `autoload.files` helpers keep the strict behavior (an unresolvable helper include is skipped with a warning, since its top-level code runs at startup). New `resolver::resolve_lenient_includes` entry; the lenient flag is threaded through include discovery and expansion. Tests: e2e (autoloaded class with a lazy dynamic include compiles and runs when unreached), resolver-level (strict errors vs lenient stub), plus the existing tolerant-skip test still passing (files helper stays strict). Example + pipeline docs.
…ary and other operators PHP parses `cond ?: $obj->prop = B` as `cond ?: ($obj->prop = B)` — the `=` binds to the adjacent lvalue inside the expression, even for complex targets such as object properties and array elements. elephc's statement-level assignment detector (`try_parse_postfix_assignment`) instead grabbed the top-level `=`, parsed everything before it (`cond ?: $obj->prop`) as the target, and rejected the short-ternary result with "Invalid assignment target" — hit by Symfony's `UnicodeString::join()`: `isNormalized($s->v) ?: $s->v = normalize($s->v)`. When the tokens before the top-level `=` parse to something that is not itself an assignable target shape (a short-ternary, ternary, binary op, or call result that merely contains a postfix access), bail to bare-expression-statement parsing, where the Pratt parser performs the adjacent-lvalue binding. Plain complex assignments (`$o->p = x`, `$a[$i] = x`, `$a[] = x`, `$o->p =& $src`) are unaffected, and a genuinely invalid target (`cond ?: f() = 5`) still errors. 5 codegen + 2 parser + 1 error test, plus example and docs.
…ses out of the closure Symfony's symfony/string and var-dumper are dragged into a closed-world render build not by the render path but by eager `autoload.files` global helpers: `Resources/functions.php` defines `u()`/`b()`/`s()` (constructing UnicodeString/ ByteString) and var-dumper defines `dump()`/`dd()` (constructing VarDumper), each behind a `function_exists` guard — the same shape as the deepclone polyfill. A page that never calls those helpers still pulled (and had to compile) the entire Unicode and var-dumper branches. Add `prune_unused_optional_helpers`: before class-reference collection, drop the definition guard of an allowlisted optional helper (u/b/s/dump/dd) when no call names it anywhere in the program assembled so far. Matching the declared function keeps it robust to the guard argument form (`Name::class` is not folded at autoload time). Refactor `polyfill_prune` so the provided-polyfill prune and this unused-helper prune share one recursion parameterized by a classify closure, and refactor `walk.rs` to collect called function names alongside class references in a single `Refs` traversal (new `collect_called_function_names`). Conservative and safe: only the program-so-far is scanned, so a later direct call from a lazily-loaded class surfaces a clean "undefined function" error, never a miscompile; a genuine call keeps the helper and its classes. 2 e2e + 3 unit tests, example, and pipeline docs.
A leading `\` before a predefined global constant — `\PHP_INT_MAX`, `\INF`, `\DIRECTORY_SEPARATOR`, `\PHP_EOL`, `\M_PI`, `\true`/`\false`/`\null`, … — only denotes the global namespace, but those constants are lexed as dedicated tokens rather than identifiers, so the fully-qualified-name path rejected them with "Expected name". Symfony's DI, kernel, and mbstring polyfill code uses this form heavily (`\PHP_INT_MAX`, `\DIRECTORY_SEPARATOR`, `-\INF`), so it blocked five render-path files at once. When `\` is followed by such a global-constant token, consume the backslash and parse the constant directly. Scoped to predefined constants via a new `is_backslashable_global_constant` predicate: `\` before anything else stays a name reference, so `\123` is still an error, and magic constants like `__LINE__` (which PHP does not namespace) are excluded. 6 codegen + 2 parser tests, example, docs.
Add PHP `goto label;` and `label:` targets, lowered to unstructured EIR branches. A `goto` becomes an unconditional `Br` to the label's block (created lazily so forward and backward jumps share one block); a `label:` opens a block reachable through any `goto`, even when the textually-preceding statement terminated. PHP variables live in memory slots rather than SSA block arguments, so the branches need no block-argument threading. Make the structured-control-flow optimizer passes label-aware so they never drop or duplicate a jump target: DCE, prune, constant propagation, and switch flattening fall back to a conservative mode when a statement list contains a label, and constant propagation clears its environment at each label join point. `termination.rs` treats `goto` as exiting the current block (like return/continue) while conservatively assuming it can leave an enclosing loop/switch. Validate label usage per function scope: a `goto` to an undefined label and a duplicate label definition are reported as compile errors instead of failing an internal EIR block-terminator check. Lexer/parser/codegen/error tests, an example, and control-structures docs.
Allow a `static` declaration to list several comma-separated variables, each with its own optional initializer, and allow the initializer to be omitted entirely. A bare `static $x;` defaults to `null`, matching PHP (`static $x;` is equivalent to `static $x = null;`). Multiple variables produce one StaticVar node per name, wrapped in a Synthetic block. Comma-separated lists with initializers work end to end: each variable gets its own persistent slot that initializes once and mutates independently across calls. An uninitialized static that stays null compiles too. Lowering an uninitialized static that later takes a concrete type is a separate follow-up (the checker does not yet widen a static's type from its uses). Parser and codegen tests, an example, and updated function docs.
Support `$callable(...)` where `$callable` is a variable holding a callable. PHP's first-class callable syntax on a variable creates a closure from the held callable; in elephc's closed world a callable-typed variable already is a callable value, so the form evaluates to that value, which can be stored and invoked like any other callable. The existing first-class-callable descriptor machinery is built around compile-time-known targets (named functions and methods), so a runtime callable variable cannot reuse it; the parser instead yields the variable's value for the `(...)` form. Closures and captured first-class callables held in a variable round-trip correctly through `$f(...)`. Parser and codegen tests, plus first-class-callable docs.
Allow an argument list to follow an instance, nullsafe, or static method call so the returned value is invoked in the same expression, e.g. `$obj->makeAdder(1)(41)` or `Box::factory()()`. The Pratt suffix loop already chained calls onto array accesses and function/closure/expr calls; method-call results are added to that set, producing an `ExprCall` over the method-call callee. Codegen tests and docs.
Allow `self::$method(args)`, `static::$method(args)`, and `parent::$method(args)`, where the method name lives in a variable. Like the named-class form `C::$method(args)`, these desugar to `call_user_func([receiver::class, $method], ...args)`, where `receiver::class` resolves `self`, `static`, and `parent` to the appropriate class. Without a following `(` the syntax stays a static property access. `self::$method` and `parent::$method` dispatch against the statically-known class. `static::$method` dispatches through the late-static-binding class when that class is resolvable; a fully-runtime inherited `static::` target shares the existing limitation of `call_user_func` with a runtime class string. Codegen tests and docs.
Allow a `for` loop's init and update sections to hold several comma-separated expressions, as in `for ($i = 0, $j = 10; $i < 5; $i++, $j--)`. Each clause parses into one statement, or a Synthetic block of statements when commas are present, so the existing `for` lowering runs the init list once and the update list after each iteration. Parser and codegen tests, plus control-structures docs.
After rebasing onto 0.25.0, several call sites added upstream do not yet account for the AST nodes this branch introduces: - The new `image`/`list_id`/`tz`/`var_export` prelude detectors exhaustively match `ExprKind` and `StmtKind`; add arms for `Clone`, `ListUnpack`, `Goto`, `Label`, and `RefAssignTarget` (mirroring the existing `pdo_prelude` detector). - `ClassMethod` gained a `variadic_type` field upstream; set it on the two reflection builtin method constructors. - `lower_closure_with_context` gained an `is_static` parameter on this branch; pass it from the value-sort comparator closure lowering.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Symfony L0 enablement work, rebased onto
main(0.25.0). This branch bundles the language, runtime,and autoload features needed to push the Symfony compile probe deeper into the framework. Opened as a
draft pending full 3-target CI.
Reference machinery (REFCELL)
REFCELLheap kind for shared references; reference assignment into array elementsand object properties, two-level nested targets, write-through on plain assignment, Mixed-typed
sources, reverse-direction
$x = &$arr[...], and foreach-by-reference value aliasing. GC suite + docs.Closures, magic methods, reflection
$thisauto-binding,Closure::bind/bindTo/call;__callStatic/__isset/__unset.ReflectionFunction/ReflectionParameter/ReflectionNamedTypeand attribute reflection metadata.Language / parser gates
goto/labels,static $a = 1, $b;lists incl. uninitialized, dynamic first-class callable$callable(...), invoking a call result$o->m()(...),self/static/parent::$method(...),comma-separated
forinit/update,clone/__clone, dynamicnew $expr(), long-formarray(...),foreacharray destructuring, list-destructuring in expression position,$expr::class,require/includeas expression operands,get_debug_type(),extension_loaded(), and assortedexpression/assignment binding fixes.
Autoload
dirname()of compile-time strings in include paths, tolerate/prune unusedautoload.fileshelpers, degrade unresolvable dynamic includes in library code to runtime fatals.
Verification
cargo buildclean (0 warnings); lib unit tests and all gate codegen tests green on macOS-aarch64after the rebase. The final commit integrates the branch's new AST nodes with 0.25.0 call sites
(new prelude detectors,
ClassMethod::variadic_type,lower_closure_with_contextarity).