feat(arrays): ksort() and krsort() take PHP's sort flags - #1059
Open
Guikingone wants to merge 4 commits into
Open
Guikingone wants to merge 4 commits into
Guikingone wants to merge 4 commits into
Conversation
|
Guikingone
added a commit
that referenced
this pull request
Sep 16, 2026
Review follow-up on #1059. AGENTS.md is explicit that `src/types/call_args/` owns named matching, duplicate detection and spread expansion, and that EIR lowering consumes `CallArgPlan` rather than rebuilding them. `plan_key_sort_args` was rebuilding them. It now asks `plan_call_args` and only re-reads the answer in SOURCE order, which is the one thing the packed-to-hash promotion needs that a parameter-indexed plan does not already say: `krsort(flags: f(), array: $a)` writes the flag first, so the receiver cannot be promoted before `f()` has run. Two shapes the plan reports and this declines, both to the shared path that owns them: a spread, which has to be evaluated before anything can be said about which element lands on the receiver slot, and a plan whose receiver slot is filled by a spread element. The planner answers in two shapes and both are handled: with no named argument it returns a passthrough whose written order IS parameter order, and with one it returns per-slot bindings carrying `source_index`. Reading only the second is what made the first attempt silently stop promoting -- caught by the existing `krsort` packed-array tests. Verified byte-identical to PHP 8.5.10 across named arguments in both orders, packed promotion with and without a flag, and the single evaluation of a side-effecting flag expression. 47 key-sort tests pass. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone
added a commit
that referenced
this pull request
Sep 16, 2026
Review follow-up on #1059. AGENTS.md is explicit that `src/types/call_args/` owns named matching, duplicate detection and spread expansion, and that EIR lowering consumes `CallArgPlan` rather than rebuilding them. `plan_key_sort_args` was rebuilding them. It now asks `plan_call_args` and only re-reads the answer in SOURCE order, which is the one thing the packed-to-hash promotion needs that a parameter-indexed plan does not already say: `krsort(flags: f(), array: $a)` writes the flag first, so the receiver cannot be promoted before `f()` has run. Two shapes the plan reports and this declines, both to the shared path that owns them: a spread, which has to be evaluated before anything can be said about which element lands on the receiver slot, and a plan whose receiver slot is filled by a spread element. The planner answers in two shapes and both are handled: with no named argument it returns a passthrough whose written order IS parameter order, and with one it returns per-slot bindings carrying `source_index`. Reading only the second is what made the first attempt silently stop promoting -- caught by the existing `krsort` packed-array tests. Verified byte-identical to PHP 8.5.10 across named arguments in both orders, packed promotion with and without a flag, and the single evaluation of a side-effecting flag expression. 47 key-sort tests pass. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone
force-pushed
the
fix/699-ksort-krsort-sort-flags
branch
from
September 16, 2026 20:52
22f51ee to
85089ea
Compare
Guikingone
added a commit
that referenced
this pull request
Sep 18, 2026
Review follow-up on #1059. AGENTS.md is explicit that `src/types/call_args/` owns named matching, duplicate detection and spread expansion, and that EIR lowering consumes `CallArgPlan` rather than rebuilding them. `plan_key_sort_args` was rebuilding them. It now asks `plan_call_args` and only re-reads the answer in SOURCE order, which is the one thing the packed-to-hash promotion needs that a parameter-indexed plan does not already say: `krsort(flags: f(), array: $a)` writes the flag first, so the receiver cannot be promoted before `f()` has run. Two shapes the plan reports and this declines, both to the shared path that owns them: a spread, which has to be evaluated before anything can be said about which element lands on the receiver slot, and a plan whose receiver slot is filled by a spread element. The planner answers in two shapes and both are handled: with no named argument it returns a passthrough whose written order IS parameter order, and with one it returns per-slot bindings carrying `source_index`. Reading only the second is what made the first attempt silently stop promoting -- caught by the existing `krsort` packed-array tests. Verified byte-identical to PHP 8.5.10 across named arguments in both orders, packed promotion with and without a flag, and the single evaluation of a side-effecting flag expression. 47 key-sort tests pass. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone
force-pushed
the
fix/699-ksort-krsort-sort-flags
branch
from
September 18, 2026 13:10
85089ea to
1e95e2d
Compare
$a = ["img10" => 1, "img2" => 1];
ksort($a, SORT_NATURAL);
EIR backend error: ksort expected 1 args, got 2
Both key sorts were unary and always compared keys with `SORT_REGULAR`. The six
`SORT_*` constants PHP defines for them did not exist either, so the argument had
no spelling to pass.
$sizes = [10 => "l", 9 => "m", 100 => "xl"];
ksort($sizes, SORT_NUMERIC); // 9, 10, 100
ksort($sizes, SORT_STRING); // 10, 100, 9
An integer key has no bytes of its own, so every byte-comparing mode has to spell
it out as decimal text first -- `zend_print_long_to_buf` in php-src, a 24-byte
buffer in the comparator's own frame here. That is the whole reason `100` lands
between `10` and `9`.
PHP resolves the comparator from `$flags & ~SORT_FLAG_CASE` and silently ignores
everything it does not recognize: `SORT_ASC`, `SORT_DESC`, a bare `SORT_FLAG_CASE`
and `999` all mean `SORT_REGULAR`. That resolution happens ONCE per call, in
`__rt_hash_key_sort_enter`, which folds a small selector into the mode word the
merge sort already carries. The comparison step then reads a number between 0 and
6 instead of reasoning about the raw flag word n log n times.
Selector `0` does not reach the new comparator at all: a flagless key sort still
runs `__rt_key_compare_regular`, untouched.
`SORT_STRING` and its case-folded twin are `__rt_strcmp` and `__rt_strcasecmp`,
the comparators `strcmp()` and `strcasecmp()` already use, so `ksort()` cannot
drift away from them. `SORT_NUMERIC` reads a string key through
`__rt_str_to_number`, which is PHP's numeric grammar rather than libc's -- no
hexadecimal, no `INF`/`NAN`, and an exponent only when a digit follows it, so
`"0x10"` is `0.0` and `"1e"` is `1.0`. Two integer keys compare exactly instead
of through a double, because `9223372036854775806` and `9223372036854775807`
round to the same `f64` and PHP orders them.
`SORT_NATURAL` is the one thing that had to be written: `__rt_strnatcmp`, a port
of php-src's `strnatcmp_ex` with its quirks intact. Leading zeros are skipped once
before the loop rather than per run, whitespace is skipped on each side
independently, a run starting with `0` on either side compares left-aligned
(`"a0.5"` before `"a0.10"`) and any other run compares by length first. Two
operands that differ only in skipped bytes compare EQUAL, which is why
`strnatcmp("a 7", "a7")` is `0`. It is a leaf with no calls: it runs once per
comparison inside an O(n log n) sort.
php-src walks one byte past the operand in its whitespace skip and relies on the
NUL its strings always carry. An elephc string is a pointer/length pair with no
terminator, so the skip synthesizes that `0` at the boundary instead of reading
whatever follows in memory.
`SORT_LOCALE_STRING` clips both operands at the first NUL and compares bytes. That
IS `strcoll` in the C locale, and the C locale is the only one an elephc program
can be in: there is no PHP-visible `setlocale()`. Calling libc would mean two heap
allocations per comparison to manufacture the NUL-terminated operands `strcoll`
wants, for the same answer.
`SORT_NATURAL | SORT_FLAG_CASE` folds ASCII `a`..`z` and nothing else. php-src
folds with libc `toupper()` there, which maps Latin-1 under Darwin's C locale and
not under glibc's -- PHP itself answers differently on macOS and Linux for a byte
above 127. Elephc gives glibc's answer on every target. ASCII keys are unaffected,
and a test pins the boundary by name.
Against host PHP 8.5.10, byte-identical: 210 sorts over 15 key sets and every mode
including `999`, `3`, `4` and a bare `SORT_FLAG_CASE`, in BOTH backends -- compiled
and `eval()`. The sets cover PHP whitespace, `0x10`/`INF`/`NAN`/`abc`, embedded
NULs, exponents and dangling exponents, leading zeros, fractional runs, negative
keys and both int64 extremes, keys past int64, single and empty arrays, bytes above
127, and integer keys mixed with string keys.
Through every receiver, since the sort relinks in place: a local, an object
property, a nested array cell, a by-reference parameter, named arguments in both
orders, and a packed array that `krsort()` promotes -- that promotion used to run
only when the call had exactly one argument. The flag expression is evaluated once,
in source order.
The x86_64 helpers were executed, not just assembled: `__rt_strnatcmp` and
`__rt_key_compare_flagged` were extracted from the emitted runtime, linked against
a C driver and run under Rosetta over the same PHP-derived cases. All three target
runtimes assemble clean.
`--heap-debug` reports `leak summary: clean` over 200 iterations of four modes: the
comparator's decimal buffer is in its own frame and nothing is allocated per
comparison.
The behavioural tests were confirmed load-bearing by forcing the resolver back to
`SORT_REGULAR`: 7 of the 12 fail, and the 5 that pass are the ones that do not
depend on the comparison (evaluation order, promotion, heap hygiene, and the two
that assert `SORT_REGULAR` behaviour on purpose).
`sort()`, `rsort()`, `asort()` and `arsort()` still take no flags; the comparison
machinery is reusable when they do. A non-numeric string in `$flags` is coerced to
`0` rather than raising PHP's `TypeError`, which is what elephc already does for
every integer builtin parameter (`str_repeat("z", "x")` answers `""`), not
something this change introduces.
Closes #699
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Review follow-up on #1059. AGENTS.md is explicit that `src/types/call_args/` owns named matching, duplicate detection and spread expansion, and that EIR lowering consumes `CallArgPlan` rather than rebuilding them. `plan_key_sort_args` was rebuilding them. It now asks `plan_call_args` and only re-reads the answer in SOURCE order, which is the one thing the packed-to-hash promotion needs that a parameter-indexed plan does not already say: `krsort(flags: f(), array: $a)` writes the flag first, so the receiver cannot be promoted before `f()` has run. Two shapes the plan reports and this declines, both to the shared path that owns them: a spread, which has to be evaluated before anything can be said about which element lands on the receiver slot, and a plan whose receiver slot is filled by a spread element. The planner answers in two shapes and both are handled: with no named argument it returns a passthrough whose written order IS parameter order, and with one it returns per-slot bindings carrying `source_index`. Reading only the second is what made the first attempt silently stop promoting -- caught by the existing `krsort` packed-array tests. Verified byte-identical to PHP 8.5.10 across named arguments in both orders, packed promotion with and without a flag, and the single evaluation of a side-effecting flag expression. 47 key-sort tests pass. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
…ment The note said php-src folds Latin-1 "under macOS's C locale". Measured, that is not where the divergence comes from: under LC_CTYPE=C, php-src folds ASCII only and prints exactly what the fixture asserts. The CLI forces C.UTF-8 at startup, and it is Darwin's single-byte table for THAT locale which also maps 0xE0..0xFE, so the same PHP source orders keys above 0x7F one way on macOS and another on glibc. Elephc folds a..z on every target, which is the LC_CTYPE=C answer. Folding through a libc table would make an emitted binary answer for the machine that compiled it. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
… also defines
The flag resolvers and the docs both named `SORT_ASC` and `SORT_DESC` as words PHP
folds to `SORT_REGULAR`, but neither constant was in the catalog, so naming one was
a compile error:
ksort($a, SORT_DESC);
error[3:11]: Undefined constant: SORT_DESC
The fixture that covers the ignored words wrote them as the bare numbers `3` and
`4`, which is how it went unnoticed; it now names the constants, which is what a
PHP program would write. They belong to `array_multisort()` and still do not
reverse a key sort -- `[9][10][A][b]` either way, byte-identical to PHP 8.5.10.
Generated docs regenerated: standard constants 155 -> 163.
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone
force-pushed
the
fix/699-ksort-krsort-sort-flags
branch
from
September 19, 2026 07:49
1e95e2d to
36f9370
Compare
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.
Closes #699.
Both key sorts were unary and always compared keys with
SORT_REGULAR. The sixSORT_*constants PHP defines for them did not exist either, so the argument had no spelling to pass.
The flag decides the answer, not the path
An integer key has no bytes of its own, so every byte-comparing mode has to spell it out as
decimal text first —
zend_print_long_to_bufin php-src, a 24-byte buffer in the comparator'sown frame here. That is the whole reason
100lands between10and9.How the mode is chosen
PHP resolves the comparator from
$flags & ~SORT_FLAG_CASEand silently ignores everything itdoes not recognize:
SORT_ASC,SORT_DESC, a bareSORT_FLAG_CASEand999all meanSORT_REGULAR. That resolution happens once per call, in__rt_hash_key_sort_enter, whichfolds a small selector into the mode word the merge sort already carries. The comparison step
then reads a number between 0 and 6 instead of reasoning about the raw flag word
n log ntimes.
Selector
0does not reach the new comparator at all: a flagless key sort still runs__rt_key_compare_regular, untouched. An emitter test asserts that comparator is notreimplemented in the new file.
What each mode reuses
SORT_STRINGand its case-folded twin are__rt_strcmpand__rt_strcasecmp, the comparatorsstrcmp()andstrcasecmp()already use, soksort()cannot drift away from them.SORT_NUMERICreads a string key through__rt_str_to_number, which is PHP's numeric grammarrather than libc's — no hexadecimal, no
INF/NAN, and an exponent only when a digit followsit, so
"0x10"is0.0and"1e"is1.0. Two integer keys compare exactly rather thanthrough a double, because
9223372036854775806and9223372036854775807round to the samef64and PHP orders them.SORT_NATURALis the one thing that had to be written:__rt_strnatcmp, a port of php-src'sstrnatcmp_exwith its quirks intact. Leading zeros are skipped once before the loop ratherthan per run, whitespace is skipped on each side independently, a run starting with
0oneither side compares left-aligned (
"a0.5"before"a0.10") and any other run compares bylength first. Two operands that differ only in skipped bytes compare equal, which is why
strnatcmp("a 7", "a7")is0. It is a leaf with no calls: it runs once per comparison insidean
O(n log n)sort.php-src walks one byte past the operand in its whitespace skip and relies on the NUL its strings
always carry. An elephc string is a pointer/length pair with no terminator, so the skip
synthesizes that
0at the boundary instead of reading whatever follows in memory.Two boundaries, stated rather than papered over
SORT_LOCALE_STRINGclips both operands at the first NUL and compares bytes. That isstrcollin the C locale, and the C locale is the only one an elephc program can be in: thereis no PHP-visible
setlocale(). Calling libc would mean two heap allocations per comparison tomanufacture the NUL-terminated operands
strcollwants, for the same answer. The one place itdiffers from
SORT_STRINGis a key with an embedded NUL, and a test pins that pair.SORT_NATURAL | SORT_FLAG_CASEfolds ASCIIa–zand nothing else. php-src folds withlibc
toupper()there, which maps Latin-1 under Darwin's C locale and not under glibc's — PHPitself answers differently on macOS and Linux for a byte above 127. Elephc gives glibc's answer
on every target. ASCII keys are unaffected, and a test pins the boundary by name.
Verified
Against host PHP 8.5.10, byte-identical: 210 sorts over 15 key sets and every mode including
999,3,4and a bareSORT_FLAG_CASE, in both backends — compiled andeval(). Thesets cover PHP whitespace,
0x10/INF/NAN/abc, embedded NULs, exponents and danglingexponents, leading zeros, fractional runs, negative keys and both int64 extremes, keys past
int64, single and empty arrays, bytes above 127, and integer keys mixed with string keys.
Through every receiver, since the sort relinks in place: a local, an object property, a nested
array cell, a by-reference parameter, named arguments in both orders, and a packed array that
krsort()promotes — that promotion used to run only when the call had exactly one argument.The flag expression is evaluated once, in source order.
The x86_64 helpers were executed, not just assembled:
__rt_strnatcmpand__rt_key_compare_flaggedwere extracted from the emitted runtime, linked against a C driverand run under Rosetta over the same PHP-derived cases (25 and 21 respectively, all passing).
All three target runtimes assemble clean.
--heap-debugreportsleak summary: cleanover 200 iterations of four modes: the comparator'sdecimal buffer is in its own frame and nothing is allocated per comparison.
The behavioural tests were confirmed load-bearing by forcing the resolver back to
SORT_REGULAR: 7 of the 12 fail, and the 5 that pass are the ones that do not depend on thecomparison (evaluation order, promotion, heap hygiene, and the two that assert
SORT_REGULARbehaviour on purpose).
Out of scope, deliberately
sort(),rsort(),asort()andarsort()still take no flags; the comparison machinery isreusable when they do. This matches the plan's own non-goals.
A non-numeric string in
$flagsis coerced to0rather than raising PHP'sTypeError. Thatis what elephc already does for every integer builtin parameter —
str_repeat("z", "x")answers""where PHP raises — not something this change introduces.The generated signature renders the default as
int $flags = 0rather thanSORT_REGULAR: asymbolic default cannot reach an eval registry binding, and both key sorts have one. The plan
anticipated this; the hand-written
docs/php/arrays.mdspellsSORT_REGULAR.Docs
docs/php/arrays.mdgains a key-sort-flags section with the mode table and both boundaries;docs/internals/the-runtime.mdgains the three new runtime rows;examples/assoc-arraysdemonstrates numeric versus string versus natural key ordering and is byte-identical to PHP. The
generated builtin pages, registries and the compatibility page were regenerated with the
documented pipeline.
.plans/ksort-krsort-php-sort-flags.mdis marked implemented, with thethree places the implementation diverged from it.
Note for reviewers, unrelated to this change
Two pre-existing behaviours turned up while verifying and were left alone:
krsort()on a packed local, then passing that local to anarray-hinted function, readsgarbage:
Reproduced on pristine
main, so it is not from this change: the packed→hash promotionrewrites the local's storage without the checker seeing it, and the callee is specialized for
a packed array. Worth its own issue.
SORT_REGULARover integer keys mixed with non-numeric string keys can order differently fromPHP.
docs/php/arrays.mdalready documents this: PHP's own key comparison is not transitivethere, so each side resolves the cycle through its own sort algorithm.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr