README.md
September 6, 2026 · View on GitHub
██████╗ ██╗ ██╗██████╗ ██╗ █████╗ ███╗ ██╗ ██████╗
██╔══██╗██║ ██║██╔══██╗██║ ██╔══██╗████╗ ██║██╔════╝
██████╔╝███████║██████╔╝██║ ███████║██╔██╗ ██║██║ ███╗
██╔═══╝ ██╔══██║██╔═══╝ ██║ ██╔══██║██║╚██╗██║██║ ██║
██║ ██║ ██║██║ ███████╗██║ ██║██║ ╚████║╚██████╔╝
╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝
[PHP, COMPILED TO BYTECODE — ON A SHARED CRANELIFT JIT]
"Zend compiles PHP to its own opcodes and walks them. phplang lowers PHP to a shared machine that other languages already run on, and lets a tracing JIT compile the hot loops."
phplang is PHP as a fusevm
frontend — a lexer/parser and compiler that lowers PHP to fusevm::Chunk
bytecode running on fusevm's bytecode VM + tracing Cranelift JIT, over a
PhpHost object heap. There is no bespoke interpreter loop: phplang is a pure
front end; execution and codegen live in fusevm — the same engine behind
zshrs,
strykelang,
awkrs,
pythonrs, and
rubylang.
It is, to our knowledge, the first compiled standalone PHP runtime. The binary
is php.
Read the Docs · Engineering Report · Builtin Reference · fusevm
Table of Contents
- [0x00] Overview
- [0x01] Pipeline
- [0x02] Usage
- [0x03] Supported Today
- [0x04] Not Yet (Later Waves)
- [0x05] Parity Fuzzer
- [0x06] Build
- [0x07] Documentation
- [0xFF] License
[0x00] OVERVIEW
phplang keeps PHP the language and throws away Zend's execution model. It lexes
and parses PHP (two-mode: inline-HTML passthrough plus <?php … ?> code), lowers
the AST to fusevm bytecode, and runs it on the shared bytecode VM with a
tracing Cranelift JIT. Arithmetic lowers to native ops so the JIT can trace hot
loops; PHP-specific behavior — loose comparison, string↔number coercion, array
semantics, the standard library — is served by the PhpHost object heap.
It carries no VM or JIT of its own. Bug fixes and JIT improvements in fusevm
land once and benefit every hosted frontend at the same time.
[0x01] PIPELINE
source ──▶ lexer ──▶ parser ──▶ compiler ──▶ fusevm::Chunk ──▶ fusevm VM + JIT
│ │ │ │
two-mode PHP AST lower to bytecode callbacks into PhpHost
(HTML/PHP) (native ops + CallBuiltin) (builtins + numeric hook)
- Scalars (
int,float,bool,string,null) ride through the VM as nativefusevm::Values. - Arrays are heap objects in
PhpHost; they travel asValue::Obj(u32)handles into that heap. - Arithmetic
+ - *lowers to native fusevm ops so the JIT can trace hot loops; a strict numeric hook supplies PHP coercion when an operand is a string/array/null or ani64op overflows./ % **, concatenation, comparisons, and everything PHP-specific lower toCallBuiltinhandlers. The hook is the sited form, so it can index the running chunk's line table and report a warning against the operator's own line. - PHP 8 operand rules are applied by every operator that reads a number. A
string with no numeric prefix (
"g",""," ","INF") makes the operation aTypeError: Unsupported operand types: string + int; one that merely trails garbage ("5g") raisesWarning: A non-numeric value encounteredand continues with its prefix. Operands resolve left before right and before each operator's own checks, so"g" / 0is aTypeErrorrather than aDivisionByZeroError.
[0x02] USAGE
php script.php # run a file
php -r 'echo 1 + 1;' # run a one-liner (no <?php tag needed)
php < script.php # run a script on stdin
php -a # interactive REPL (persistent state per line)
php --dump-bytecode f.php # print the lowered fusevm bytecode
php --tiers f.php # run it, then report which fusevm tiers took it
The three script entry points name the script differently, and the name is
observable: every diagnostic quotes it and __FILE__ returns it. A file is its
resolved path, -r is Command line code, and stdin is Standard input code —
the same three names the reference uses.
A man/php.1 man page and runnable examples/*.php ship with the crate.
[0x03] SUPPORTED TODAY
A working core, grown outward from the sibling frontends. Implemented and tested
end-to-end (see tests/basic.rs):
<?php … ?>tags with inline-HTML passthrough;<?=short echo;#,//, and/* */comments.- Scalars, single- and double-quoted strings with escapes and the full set of
interpolation forms —
"$v", the simple one-level"$o->p"/"$a[key]"(where an unquoted key is a string, not a constant), the complex"{$expr}"for any expression, and the legacy"${v}". - Heredoc (
<<<EOT) and nowdoc (<<<'EOT'): a heredoc body is the double-quoted language minus the\"escape (a"there needs none), a nowdoc body is verbatim to the byte. PHP 7.3's flexible closing delimiter is honoured — its indentation is stripped from every body line before anything is interpolated, and a line indented less than it is a parse error naming that line. Only the exact label closes a body, the newline before the label belongs to the delimiter, and the label may be followed by any token. - Variables, arithmetic (
+ - * / % **), string concat (.), compound assignment (+= .=…), pre/post++/--. - Loose/strict comparison (
== != === !== < > <= >=, PHP-8 string↔number ordering), short-circuit&& || and or, ternary?:(incl. the elvis short form), null-coalesce??, the nullsafe operator?->(a null receiver short-circuits the whole REMAINING CHAIN to null —$a?->b->c["k"]->d()evaluates no member, no subscript and no argument past the?->, while a?->inside an argument keeps its own extent),!, and(int)/(float)/(string)/(bool)casts. An object with__toStringcompared against a string is cast to it first, aszend_comparedoes. - Indexed, associative, and appended (
$a[] =) arrays with PHP value semantics — assigning, passing, returning or storing one hands over a copy (deep through nested arrays; an object inside stays a handle), while$b = &$aand a&$xparameter share; index read/write; deep and nested lvalues ($a[b][c] =,$a[b][] =, compound and++/--on elements); the by-reference array mutators (array_push/pop/shift/unshift/splice). if/elseif/else,while,do … while,for,foreach ($a as [$k =>] $v),switch(with fall-through),break,continue,return;matchexpressions (a no-arm/no-defaultmatch throws\UnhandledMatchError, as PHP 8 does). Each of the six control structures is also read in PHP's ALTERNATIVE spelling —if (…): … endif;,endwhile,endfor,endforeach,endswitch,enddeclare— including bodies cut in half by?> html <?php, which is the spelling templates are written in.printis an operator, not a statement: it writes its operand likeechoand evaluates to the int1, so$r = print "x"andvar_dump(print $v)are expressions. It binds looser than=and tighter thanyield.- The three WORD logical operators
and,orandxor, at their own precedence BELOW assignment — which is the only reason the language has them next to&&/||:$x = a and bassignsafirst.xorhas no short circuit and answers a bool. - User
functions with positional, default ($x = 1) and variadic (...$rest) parameters, recursion, argument unpacking at EVERY call site (f(...$args),$f(...$args),$o->m(...$args),C::s(...$args),new C(...$args)) and inside an array literal ([...$a, ...$b], keys renumbered for integers and kept for strings), either of which also drives a Generator or a Traversable, and named arguments (f(name: 1, other: 2), mixable with positional, order independent, extra names collected into a variadic); anonymousfunction () use (...) { … }closures — by value, and by reference withuse (&$v), which binds the closure's name to the enclosing variable itself — andfn (…) => …arrow functions. - First-class callable syntax (
strlen(...),$obj->method(...),Cls::method(...),$callable(...)) — each yields aClosurethat forwards its arguments to the referenced function/method. - Closure rebinding:
Closure::bind($fn, $obj, $scope),$fn->bindTo($obj, $scope), and$fn->call($obj, …)rebind$thisand the private/protected access scope; a closure created inside a method auto-binds the current$thisand class scope. - Generators (
yield): a function whose body containsyieldreturns a lazyGenerator—yield $v,yield $k => $v, bareyield, andyield from(delegating to an array,Traversable, or another generator). TheGeneratorobject supportscurrent/key/next/valid/send/throw/getReturnand drivesforeachlazily (side effects interleave; infinite generators work). Implemented as host-side stackful coroutines (corosensei) — the fusevm VM run loop executes on the coroutine's stack, soyieldsuspends it with one stack switch and no VM change. - Classes/OOP:
new, instance properties and methods,$this, constructors (with property promotion), class constants,::class, static methods/constants,self::/parent::(inside a trait both name the COMPOSING class, and inside a closure the class it is bound to —(function () { return self::K; })->call($o)— both resolved when the code runs), constants inherited from an implemented or extended interface, late static binding (static::—static::class,new static,static::CONST,static::$prop,static::m()— resolves to the class the call was made on, andself::/parent::/static::calls forward it), single inheritance, interfaces (implements, interfaceextends), theinstanceofoperator, traits (use Trait;member merging, withinsteadof/asadaptation blocks resolving collisions and binding extra names or visibilities — an unresolved collision is a fatal error raised when the declaration is reached, as the reference raises it), and anonymous classes (new class(args) extends P implements I { … }, namedBase@anonymousafter the parent, else the first interface, elseclass).abstractclasses and interfaces reject direct instantiation (newon either is aCannot instantiate …error). References —$b = &$a, references to a container slot in either direction ($r = &$a['x']['y'],$r = &$o->p,$a[] = &$v,$o->p = &$v), return-by-reference (function &f()),foreach ($a as &$v), by-reference parameters (function f(&$x)) on every kind of call — a free function, an instance or static method, a closure, an arrow function, and a?->call that actually ran — with a declared scalar type coerced through the reference before the body runs (function f(int &$x)called with"5"leaves the caller's variableint(5)), and by-reference destructuring targets ([&$x, $y] = $ain both spellings, keyed, nested, alongside holes, and insideforeach, each aliasing the subject's element so a write through the target reaches the subject); a referenced element stays shared across an array copy andvar_dumpmarks it with&, as PHP does. A&inside a value array ($arr = [&$a]) is a distinct feature and is rejected rather than copied. Namespaces are accepted in a flat model (namespace X;/use A\B\C;; qualified names fold to their short name). - The magic constants
__LINE__,__FILE__,__DIR__,__FUNCTION__,__CLASS__,__METHOD__,__NAMESPACE__and__TRAIT__, resolved where they are WRITTEN rather than where the call arrives:__CLASS__in an inherited method names the class that declared it, and a closure carries the PHP 8.4 name{closure:<scope>:<line>}built from the scope it was written in (nesting composes —{closure:{closure:f():3}:4}). The two answers a parse cannot settle come from the running frame:__CLASS__inside a trait method is the class that USED the trait, and inside an anonymous class it is the generatedclass@anonymousnameget_classreports.__FILE__is whatever the entry point named the script — a resolved path,Command line codefor-r, orStandard input codefor a script on stdin — and__DIR__falls back to the working directory when there is no file. In the flat namespace model__NAMESPACE__is the declared namespace in full, while class and function names stay unqualified. - Enums (PHP 8.1): pure enums (
enum Suit { case Hearts; … }withSuit::Hearts,->name,Suit::cases(), singleton===identity) and backed enums (enum Status: string { case Active = 'active'; }with->value,Status::from(),Status::tryFrom()); enums may declare methods and constants and satisfyinstanceof UnitEnum/BackedEnum. A case renders as the case, not as the object behind it:enum(Suit::Hearts)fromvar_dump,\Suit::Heartsfromvar_export,Suit Enum:string ( … )fromprint_r, its backing value fromjson_encode(a pure enum fails the encode withJSON_ERROR_NON_BACKED_ENUM), andE:12:"Suit:Hearts";fromserialize, whichunserializeresolves back to the very same singleton. - Exceptions:
throwas a statement and a PHP-8 expression ($x ?? throw …),try/catch (A | B $e)/finallywithfinally-always semantics (it runs on return, throw, break, and continue out of the guarded body), a built-in exception hierarchy (Exception/Errordisjoint roots underThrowable, plusRuntimeException,LogicException,InvalidArgumentException,TypeError,ValueError,UnhandledMatchError,DivisionByZeroError) that user classes can subclass, andgetMessage()/getCode()/getPrevious()/__toString(). - Diagnostics. Every diagnostic is written TWICE, under two independent ini
flags, exactly as the reference writes it:
display_errorsputs the copy the program sees on stdout, interleaved with its output and inside any openob_startbuffer, andlog_errorsputs aPHP-prefixed copy on stderr. Both default on;-d display_errors=0leaves only the stderr copy and-d log_errors=0only the stdout one. A fatal and a parse error are not exempt from either, nor from theerror_reportingmask —php -d error_reporting=0runs an uncaught exception to completion in silence and still exits 255.trigger_errorraises a real diagnostic through the same path, so it obeys all three gates, rejects a level that is not one of the fourE_USER_*with the reference'sValueError, and ends the request onE_USER_ERROR. The diagnostics themselves cover undefined variables, array keys and properties, array offsets on a non-array, string offsets past the end, the++/--cases that have no effect, and PHP 8.2'sCreation of dynamic property C::$p is deprecated.isset(),empty()and??read in PHP's isset mode and stay silent, as do writes, auto-vivification and by-reference output arguments.@is separate and dynamic: the operand is evaluated normally and every diagnostic raised while it runs is dropped, including those raised from inside the library functions it calls (@preg_match('/[a', $s)). Which of them are displayed is theerror_reportingmask, writable byerror_reporting(),ini_set('error_reporting', …), orphp -d error_reporting=…. Only the-dpath runs the php.ini constant-expression scanner, soE_ALL & ~E_DEPRECATEDis understood there and reads as plain integer0throughini_set— the reference behaves the same way. - Compile-time notices are raised while the source is READ, so they precede
every byte the program writes and fire whether or not the code carrying them
runs —
Using ${var} in strings is deprecated, use {$var} insteadis the one PHP 8.2 added. A run-timeerror_reporting(0)cannot retract one; only the startup level (-d) applies. - Attributes (
#[Attr],#[Ns\\Attr(1, [2])]) parse everywhere a declaration can carry them — class, function, method, property, class constant, enum case, parameter.#[AllowDynamicProperties]is honoured, and inherited by subclasses. - Library argument errors throw. A standard-library function given arguments
it rejects raises the catchable exception PHP raises —
ValueError,DivisionByZeroError— with the library call itself as frame#0of the trace (#0 <file>(<line>): range(9, 10, 2)), and a#[\\SensitiveParameter]argument masked asObject(SensitiveParameterValue)exactly as PHP masks it. - Property overloading —
__get,__set,__issetand__unsetfire for a property the object does not carry, whether because no class declared it, because it wasunset, or because its visibility puts it out of reach of the reading scope. They are consulted BEFORE the access error, so a class defining them never reportsCannot access private property. A magic method is not re-entered for the property it is already handling, so__get($n) { return $this->$n; }terminates. The four questions PHP asks differently are all distinguished:isset()asks__issetalone,empty()and??follow a true__issetwith__get(and??falls back to__getwhen there is no__isset, whereempty()does not), and a plain read asks__get. - Property access errors throw. Reading, writing or unsetting an out-of-reach
property raises a catchable
Error: Cannot access private property C::$xnaming the class that DECLARED it, rather than aborting the process.isset()never throws — asking is always allowed. - Method overloading —
__calland__callStaticcatch a call to a method the class does not declare or cannot reach, receiving the name plus one array of the arguments. The instance and static forms are distinct: a class with only__calldoes not answerC::m(). Every call form routes through them, includingcall_user_func([$o, 'm']), sois_callable([$o, 'm'])is true for a name only the catch-all handles whilemethod_existsstays false. __invokemakes every instance of the declaring class callable, and it is recognized wherever a callable is:$obj(…),is_callable($obj), and any builtin taking a callback (array_map,usort,array_filter,Closure::fromCallable). One decoder inhost::call_valuereads every callable form — closure handle,"function","C::m",[$obj, "m"],["C", "m"], and an__invokeobject — so no two call sites can disagree about what is callable.- Call errors throw.
Call to undefined method C::m(),Call to undefined function f()andCall to private method C::m() from global scopeare all catchableErrors, with the trace starting at the call site (no frame is invented for the callee that does not exist). ArrayAccess—$o[k],$o[k] = v,$o[] = v,isset($o[k])andunset($o[k])dispatch tooffsetGet/offsetSet/offsetExists/offsetUnseton any class implementing it.isset()asksoffsetExistsand nothing else;??follows a trueoffsetExistswithoffsetGet.Countablebackscount(), which rejects anything else with the reference'sTypeErrorrather than answering 1. The SPL prelude classes declare both, so$fixedArray[0],$arrayObject['k']andcount($storage)work through the ordinary syntax.Stringableis implied by__toString, whether or not a class names the interface, exactly as PHP 8 implies it.- Closures and generators are objects. A closure is an instance of
Closureand a generator one ofGeneratortois_object,get_class,::class,get_debug_type,instanceof,is_a,is_subclass_of,method_exists,get_object_varsand every diagnostic that names a value's type — and$gen instanceof Iterator/Traversableis true, through an engine ancestry table rather than a class declaration.Closureis also the one class the reference gives an identity-only==. class_exists/interface_exists/trait_exists/enum_existseach answer for ONE declaration kind: aninterface I {}makesinterface_exists('I')true andclass_exists('I')false, while an enum is both an enum and a class. The engine's own types answer too —ClosureandGeneratoras classes,Traversable,Iterator,IteratorAggregate,Countable,ArrayAccess,Stringable,JsonSerializable,Throwable,UnitEnumandBackedEnumas interfaces.class_implementsandclass_usesreport the real lists.__toStringis invoked wherever a value becomes a string:echo,print,.concatenation, interpolation, the(string)cast /strval,implode's elements, and the library functions whose parameters PHP declares asstring.- Integer literals in every base (
0xFFhex,0755/0o17octal,0b101binary,1_000separators); predefined constants (PHP_INT_MAX,PHP_EOL,M_PI, theSORT_*/FILTER_*/JSON_*/… flag families) plusdefine/defined/constantand theconst NAME = expr, …;declaration (top-level, as PHP's grammar has it — a redefinition through either spelling warns and keeps the first value); and superglobals ($_SERVER,$_ENV,$_GET/$_POST/…,$GLOBALS,$argv/$argc) auto-global across every scope. - The
DateTime/DateTimeImmutable/DateIntervalclasses and the SPL data structures (SplStack,SplQueue,SplDoublyLinkedList,SplFixedArray,ArrayObject,SplObjectStorage,SplPriorityQueue,SplMinHeap/SplMaxHeap) plusstdClass, all as PHP preludes; output buffering (ob_start/ob_get_clean/…), variadic introspection (func_get_args/func_num_args),fopenfile streams (fread/fwrite/fgets/fseek/fclose), theunset()construct,spl_object_id, and the@error-suppression operator. exit/die— the request ends where they stand. Parentheses and the argument are both optional; an int becomes the process exit status (modulo 256, soexit(300)leaves 44 andexit(-1)leaves 255), a string is printed and the status is 0, a bool or float narrows, an explicit null is deprecated, and anything else is aTypeError. The unwind is not catchable and does not run afinally, but open output buffers still flush. PHP 8.4 also registered them as callable functions, sofunction_exists("exit")is true and$f = "exit"; $f(3);works.- A large standard library (see the
builtin reference
for the current surface — it is generated from the same corpus a test pins to
the runtime's registration tables, so it cannot drift), incl. bcmath and gmp
arbitrary precision, split into category modules under
src/stdlib/and consulted through a per-category dispatch chain:- strings —
str_*,substr*,strpos/stripos/strrpos,strstr,strtr,sprintf/vsprintf/sscanf,number_format,nl2br,addslashes,str_rot13,similar_text,levenshtein,mb_*, … - arrays —
array_map/filter/reduce/merge/slice/column/chunk, thesort/usort/natsortfamilies,array_diff/intersect(+_key/_assoc),compact/extract, the internal-pointer family, … - math —
abs/floor/ceil/round/sqrt, full trig + hyperbolic + inverse,hypot/fdiv/fmod, base conversions (dec*/*dec/base_convert),rand/mt_rand/random_int. - ctype — the
ctype_*predicates. types —is_*,gettype,get_debug_type,serialize/unserialize,var_dump/print_r/var_export. Objects render in full:print_rannotates a non-public property ([b:protected],[c:P:private]),var_exportemits\P::__set_state(array( … ))((object) array( … )for astdClass), andserializemangles the property keys the way the engine stores them ("\0*\0b","\0P\0c").unserializerestores an object WITHOUT running its constructor, turns an unknown class into__PHP_Incomplete_Class, and reproduces the reference's diagnostics — including the byte offset it blames and the "Extra data" case, where trailing bytes warn but keep the value. - preg —
preg_match/match_all/replace/replace_callback/split/quote/grep(byte-mode by default, Unicode with/u). Look-around, backreferences, atomic groups and possessive quantifiers all work: a pattern theregexcrate will not compile is retried onfancy-regex.$matchesis a real by-reference OUT parameter, so it defines the caller's variable whether or not it existed, and a(?<name>…)group appears under its name as well as its index. - datetime —
time/mktime/date/gmdate/checkdate/strtotime(UTC). - hash —
md5/sha1/hash/crc32/hash_hmac. encoding —base64_*,bin2hex/hex2bin, quoted-printable,utf8_*. url —urlencode/rawurlencode(+decode),http_build_query,parse_url,parse_str(with thePHP_URL_*component selectors). - json —
json_encode,json_decode(objects tostdClass, or to arrays under$associative/JSON_OBJECT_AS_ARRAY),json_last_error(_msg). filter —filter_var(VALIDATE_INT/FLOAT/BOOLEAN/EMAIL/URL/IP/DOMAIN/REGEXP,SANITIZE_*). mbstring —mb_str_split,mb_convert_case,mb_strpos/rpos,mb_ord/chr,mb_convert_encoding,mb_detect_encoding. - fileio —
file_get_contents/put_contents,file,fopen-free file ops (file_exists,is_file/dir,unlink,mkdir,scandir,copy,basename/dirname/pathinfo,realpath,getcwd, …). - reflection —
class_exists,method_exists,property_exists,get_class,get_parent_class,get_object_vars,get_class_methods,class_parents,is_a/is_subclass_of. callable —call_user_func(_array) (incl. array/Class::methodcallables),function_exists. misc —strnatcmp/strnatcasecmp,soundex,str_getcsv,array_walk_recursive,array_find/array_any/array_all,array_udiff/array_multisort. - system —
getenv/putenv,phpversion,php_sapi_name,php_uname,getmypid,extension_loaded,get_defined_constants,get_declared_classes.
- strings —
[0x04] NOT YET (LATER WAVES)
True (non-flat) namespaces with as alias remapping. A few current deviations,
documented in-code:
- Only a SCALAR type declaration is enforced —
int,float,string,booland their?nullable forms, on a parameter or a return. Every other type is parsed and carried but checks nothing: a union (int|string), an intersection, a class name,array,iterable,callable,mixed,object, and the return-onlyvoid/never/static. A value that would not satisfy one of those passes through where the reference raises aTypeError. declare(strict_types=1)is whole-program rather than per-file. Upstream reads the mode from the file containing the CALL, so a strict file calling a non-strict file's function still checks strictly; phplang has noinclude, so a run is exactly one file and the two readings coincide. Ifincludeis added, this becomes a real divergence and the flag has to move onto the call site.- A callback invoked BY a library function (
array_map,usort) is checked in whatever mode the program declared. Upstream treats an internal caller as having no strict-mode file and so coerces, whilecall_user_funcforwards the caller's mode; phplang forwards it in both cases. - The UNCAUGHT rendering of a parameter/return
TypeErrornames the CALL site where the reference names the function's DEFINITION —in file:9rather thanand defined in file:2— because no definition line is recorded for a function.getMessage()itself is byte-exact, which is what acatchsees. - Default parameter values are not restricted to constant expressions, and a default is not checked against the parameter's declared type (upstream checks it once, at declaration).
- The by-reference OUT parameter is implemented for
preg_match/preg_match_all/preg_replace(_callback)/parse_str/similar_text/str_replace/settype/array_multisortand not for the rest of the library —sscanf's trailing arguments are the one that remains, sosscanf($s, "%d %s", $a, $b)returns the parsed array where the reference returns the count and fills$a/$b. The two-argument form is exact. - A diagnostic names the statement's line. PHP names the line of the expression, so a statement spanning several lines reports its first.
- A
preg_*pattern the REFERENCE also rejects reproduces itsWarningand itspreg_last_error()state. Pattern RECURSION is the one construct that now compiles and answers WRONGLY rather than failing:preg_match('/\((?:[^()]| (?R))*\)/', '(a(b))')matches in both engines, but the reference captures(a(b))where this one captures(b). There is no diagnostic to copy and no error sentinel to return — the answer is simply not the reference's, which makes it the most dangerous shape a gap can take. Conditional groups ((?(1)…|…)), look-around, backreferences, atomic groups and possessive quantifiers all match the reference. - A pattern that only the
fancy-regexengine will take matches as if/uwere set, because that engine works over&str:.is one codepoint rather than one byte. This is visible only for a NON-ASCII subject, and only for a pattern the byte engine already refused. - A syntax error reproduces PHP's
unexpected <token>text but not the, expecting "X" or "Y"clause that often follows it: the expected set comes out of PHP's generated LALR tables, not the grammar as written here. var_dump's#Nobject number andspl_object_idagree with each other, but PHP reuses a freed object's number and phplang's arena never frees, so the two agree only until an object becomes unreachable.ini_get/ini_setknow PHP core plusdateandpcre— the two extensions PHP 8 cannot be built without — at the values the reference reports for them with no php.ini loaded. A name belonging to an optional extension, or one whose default is the build's install prefix (extension_dir), reads backfalserather than a machine-specific guess.ini_setdoes not model per-setting VALUE validation: the reference refusesini_set('memory_limit', '20')with a message quoting its live memory usage, which is not a reproducible number.
Persistent bytecode caching and AOT (--build) —
present in the sibling frontends — are not wired yet; an LSP server (--lsp) and
a DAP debug adapter (--dap, with source-line and function breakpoints, stepping,
call stack, locals, and expression evaluate) are.
[0x05] PARITY FUZZER
parity-fuzz is a differential fuzzer: it generates seed-deterministic PHP
snippets, runs each through both the reference php and phplang, and reports
every case where stdout, stderr or the exact exit code differs. It is a
development tool — it needs a reference php on PATH, so CI never runs it.
Neither side is run with error_reporting turned down: PHP writes
Warning/Deprecated/Fatal error to stdout and a PHP -prefixed copy to
stderr, so both copies are part of what is compared.
All three observables are compared because each omission was a blind spot
rather than a decision. stderr used to be piped to /dev/null, which made the
harness structurally incapable of reporting a stderr-only divergence however
many cases it ran; the exit code used to be compared only as zero-vs-nonzero,
which made exit(3) and exit(9) the same answer.
cargo build --bin parity-fuzz
./target/debug/parity-fuzz --count 5000 # fuzz 5000 cases
./target/debug/parity-fuzz --count 500 --mode enums # 500 cases of ONE mode
./target/debug/parity-fuzz --once --seed 1234 # replay one case, both sides
--mode NAME generates that mode's cases rather than filtering the rest away,
so --count 500 --mode enums compares 500 enum programs. A divergence prints
the case SEED, which --once --seed <it> rebuilds exactly; add the same
--mode when the run that found it was filtered. PHPLANG_FUZZ_PHP pins the
oracle and is refused unless it is a reference PHP — pointing it at phplang
would compare the binary under test against itself and report a clean sweep.
Generators are biased toward where a PHP frontend is likely to disagree with the
reference: float formatting, integer division/modulo signs, ** precedence,
loose-vs-strict comparison, sort ordering, sprintf/number_format, and
string↔number coercion, and the PCRE constructs the regex crate lacks.
Divergences are delta-debugged to a minimal reproducer and grouped by signature;
a full report lands in target/parity-fuzz/divergences-<pid>.txt, named for the
run so concurrent invocations against one checkout cannot overwrite each other.
The exit status answers did this run measure what it was asked to, not merely
did it find a disagreement. A run exits non-zero when no cases ran, when every
case that ran was skipped (the reference timing out on all of them), when a case
reached a worker and produced no verdict, or when a case agreed only because the
reference printed nothing. Each is named in a closing RUN NOT CLEAN line, and
the summary reports the skipped and barren counts even at zero — a clean number
is only evidence if those are visible next to it.
A clean run over the existing modes proves nothing about a construct no generator emits, and that is where the divergences have actually been. Before picking a target, cross-reference the generator modes against the registered library surface:
# every registered builtin that no generator mode ever emits
grep -rhoE '"[a-z_][a-z0-9_]{2,}"' src/stdlib/*.rs src/builtins.rs | tr -d '"' \
| sort -u | while read f; do
php -r "exit(function_exists('$f')?0:1);" &&
! grep -q "$f" src/bin/parity_fuzz.rs && echo "$f"
done
Round 7 found json_decode, ctype_*, parse_url, strip_tags, __invoke,
compact, md5 and base64_* on that list, and every one of them was carrying
a bug. Round 8 found sscanf, addcslashes, count_chars, strtok,
substr_compare and the array forms of substr_replace on it, with the same
result. Round 10 ran the same test over the SYNTAX instead of the library — a
grep for <<<, yield, enum , trait , ... and a ?-> past the first
link returned zero hits over the generators — and every one of those six was
carrying a bug too, <<< not being implemented at all. Round 11 repeated it and
found endif/endwhile/endfor, clone, __call, get_class, is_a(,
class_exists and func_get_args at zero: the entire alternative control-
structure syntax was a parse error, and the reflection surface did not know a
closure was an object. A curated corpus cannot report a construct nobody
captured, so a mode should be added for the family FIRST and the fix written
against what it reports.
Sampling the corpus is itself easy to get wrong. The mode is chosen from
seed >> 7, so consecutive seeds share one — seeds 1..6000 reach only the
first 47 of the 82 modes, and a survey over them will report every later mode's
constructs as absent. Sample per mode instead:
for m in $(perl -ne 'print "\$1\n" if /name: "([a-z0-9_]+)",/' src/bin/parity_fuzz.rs); do
seq 1 60 | xargs -P 12 -I{} target/debug/parity-fuzz --once --mode "$m" --seed {} \
| perl -ne 'if(/^prog : /){$p=1; s/^prog : //} elsif(/^oracle: /){$p=0} print if $p'
done > corpus.txt
A clean divergence count only means something alongside the two numbers printed
under it. skipped counts cases that never reached a comparison — the reference
timed out, or either side failed to run — because a mode whose programs all time
out on the reference would otherwise report zero divergences forever. barren
counts cases where both sides agreed only by failing with nothing on stdout:
those ran and matched, but prove nothing about the behaviour they were written
to exercise, and two different failures are indistinguishable there. A run is
worth quoting when both are 0.
[0x06] BUILD
cargo build
cargo test
phplang is a standalone crate (an explicit empty [workspace] stops cargo
walking up to the meta parent). fusevm is pulled from crates.io with the jit
feature.
[0x07] DOCUMENTATION
- Docs hub — https://menketechnologies.github.io/phplang/
- Builtin reference — https://menketechnologies.github.io/phplang/reference.html
- Engineering report — https://menketechnologies.github.io/phplang/report.html
- fusevm — https://github.com/MenkeTechnologies/fusevm (the shared VM)
- Source — https://github.com/MenkeTechnologies/phplang
[0xFF] LICENSE
MIT — free and open source. See LICENSE.