elephc
AGENTS.md812 starsChanged 42 days agoMITView on GitHub
A PHP-to-native compiler written in Rust. Compiles a static subset of PHP to native assembly for the supported target matrix, producing standalone binaries or host-consumable libraries. No interpreter, no VM, no runtime dependencies. Before contributing, read CONTRIBUTING.md in full. It holds the complete step-by-step recipes — adding an operator, a statement type, a built-in function, an EIR optimization pass, and a crate-backed --with-<crate> feature — plus the assembly comment policy, the coding style, and the Pull Request workflow. This guide…
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,700 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository6 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- analyze-using-rustroverSkill
- doc-updateSkill
- prepare-release-changelogSkill
- update-builtin-docsSkill
- verify-releaseSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
59 rules. About 0 are common, 26 are distinctive, and 33 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈26
Distinctive · estimate
This author's own choices. Worth a look.
33
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 13
All supported targets are first-class targets.
- Distinctive: line 17
When examples or internals docs use ARM64 snippets for readability, treat them as examples only.
- Very specific: line 65
While developing a feature: run only the tests for that feature or regression (cargo test test_my_feature).
- Very specific: line 66
Scope to a single test binary: prefer cargo test --test codegen_tests <filter> over a bare cargo test <filter>.
- Distinctive: line 67
For target-sensitive changes: run the smallest relevant macOS/Linux focused tests locally when they materially reduce risk.
- Distinctive: line 73
Before committing code changes, run the smallest useful focused tests and hygiene checks that cover the implementation.
- Distinctive: line 98
Bug fix: must include a regression test that would have caught the bug
- Very specific: line 170
Every bridge entry must declare a reviewed MonitoringPolicy.
- Very specific: line 262
If it introduces variables or hidden temporaries, update EIR local/temp declaration in src/ir_lower/context.rs and frame-layout allocation …
- Very specific: line 267
When adding a new ExprKind or StmtKind, check every AST-walking pass.
- Very specific: line 314
Backend implementation stays typed and target-aware: Add or extend a RuntimeFnId/RuntimeCallTarget implementation under …
- Distinctive: line 327
Add codegen + error tests (include a case-insensitive or namespaced call for PHP-visible builtins).
- Very specific: line 333
Before opening a PR that adds, removes, or changes PHP-visible builtins, run the update-builtin-docs skill or the equivalent CI sequence: …
- Distinctive: line 356
All function-like call surfaces must share the same argument rules instead of normalizing locally in individual emitters:
- Very specific: line 360
If a codegen surface uses an internal signature with hidden parameters, such as closure captures, pass the caller-visible regular parameter …
- Very specific: line 365
When adding or extending a builtin, verify first_class_callable_builtin_sig() as well as the direct builtin signature so first-class …
- Very specific: line 371
If hidden named-argument temporaries are introduced, update src/ir_lower/context.rs and EIR frame placement so slots are allocated before …
- Very specific: line 377
Update src/optimize/effects/ if purity, variable reads/writes, call effects, filesystem/runtime state, exceptions/fatals, or by-ref …
- Distinctive: line 378
Do not mark a call as pure if it can read or write globals, files, environment, runtime heap state, object properties, array contents, …
- Very specific: line 379
Keep constant folding in src/optimize/fold/ limited to PHP-equivalent results.
- Very specific: line 380
Add optimizer regression tests under tests/codegen/optimizer/ when DCE, constant propagation, control-flow pruning, or folding can observe …
- Distinctive: line 391
Keep cleanup paths balanced across normal returns, early exits, throws/fatals, and control-flow merges.
- Very specific: line 392
Add focused tests in tests/codegen/runtime_gc/ for ownership, aliasing, cycles, heap debug, stack args, and COW changes.
- Distinctive: line 401
Multi-responsibility files: should be split once they start accumulating unrelated concerns, even if the line count is not yet extreme.
- Distinctive: line 418
treat 500 LOC as a warning sign
- Distinctive: line 419
treat mixed responsibilities as the real trigger for refactoring
- Distinctive: line 420
do not split a file that owns one coherent feature just to satisfy the number
- Very specific: line 426
Every repo-owned Rust source file (*.rs) must start with a module-level Rustdoc preamble before any use, mod, item, or test helper code.
- Very specific: line 456
Keep preambles concise and factual.
- Distinctive: line 460
Every explicit Rust function in repo-owned Rust source files must have a concise docblock explaining what that function does.
- Very specific: line 470
Prefer helpers from src/codegen_support/abi/ for registers, stack slots, frame layout, argument materialization, symbol addresses, and …
- Distinctive: line 473
Avoid hardcoding ARM64 register names, x86_64 register names, syscall numbers, object formats, or stack alignment rules in shared lowering …
- Distinctive: line 474
Do not add an ARM64-only runtime helper, builtin emitter, ABI path, or ownership cleanup path unless the feature is intentionally …
- Very specific: line 486
Labels: use ctx.next_label("prefix") — global counter prevents collisions across functions
- Very specific: line 491
Every emitter.instruction(...) call must have an inline // comment aligned to column 81, with // -- description -- block comments before …
- Very specific: line 505
PHP-derived syntax must be 100% compatible with PHP: When elephc implements a PHP construct (variables, operators, keywords, built-ins), it …
- Very specific: line 513
When in doubt, test with php -r '...' to verify behavior.
- Very specific: line 570
All docs files are Markdown with YAML frontmatter compatible with Astro content collections.
- Very specific: line 583
No navigation links ([← Back], Next:, etc.) — Astro handles navigation
- Distinctive: line 584
Use standard Markdown (CommonMark).
- Distinctive: line 588
Documentation must be kept up to date: When adding a new feature:
- Distinctive: line 594
If a feature was previously listed as "not supported", remove that note.
- Very specific: line 595
If there are known incompatibilities with PHP, document them in docs/php/types.md (incompatibilities section).
- Very specific: line 596
Update docs/README.md index if adding a new page.
- Very specific: line 602
Add planned work as [ ] under the version where it is expected to ship.
- Very specific: line 603
When an implementation lands, mark its existing item [x].
- Distinctive: line 606
When all items in a version are completed, the version is considered done.
- Very specific: line 613
Keep [Unreleased] empty between releases.
- Distinctive: line 614
Only an explicit release-preparation task may add, move, rewrite, or remove changelog bullets.
- Very specific: line 616
If the target version is not known, stop and ask for it before editing the changelog.
- Distinctive: line 618
When cutting a release:
- Distinctive: line 620
Add a new section at the top (under the header), above the previous version:
- Distinctive: line 627
Keep entries concise (usually one or two bullets), describe what shipped — not the implementation — and use the absolute release date.
- Distinctive: line 628
Add a matching compare link at the bottom of the file, also newest first, immediately above the previous version's link:
- Very specific: line 635
Never change elephc's Cargo package version in Cargo.toml or Cargo.lock.
- Very specific: line 640
Use commit message prefixes such as feat:, fix:, chore:, docs:, refactor:, or test:
- Distinctive: line 641
Keep commit messages concise
- Distinctive: line 642
Run the focused pre-commit verification above before committing code changes.
- Very specific: line 644
Never run cargo fmt in this repo.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
elephc — Developer Guide
What is this
A PHP-to-native compiler written in Rust. Compiles a static subset of PHP to native assembly for the supported target matrix, producing standalone binaries or host-consumable libraries. No interpreter, no VM, no runtime dependencies.
Read CONTRIBUTING.md first
Before contributing, read CONTRIBUTING.md in full. It holds the complete step-by-step recipes — adding an operator, a statement type, a built-in function, an EIR optimization pass, and a crate-backed --with-<crate> feature — plus the assembly comment policy, the coding style, and the Pull Request workflow. This guide covers the architecture, invariants, and context that go alongside those recipes; it deliberately does not repeat them.
Supported target policy
All supported targets are first-class targets. The supported target matrix is currently macos-aarch64, ios-arm64, ios-sim-arm64, linux-aarch64, and linux-x86_64.
Do not design or land codegen/runtime features as ARM64-first with x86_64 treated as a later port. New features, builtins, runtime helpers, optimizer assumptions that affect emitted code, ABI behavior, and ownership/GC paths must either support every supported target in the same change or clearly isolate an intentionally unsupported path with diagnostics, tests, and documentation. A feature is not considered done while any supported target has a missing runtime symbol, reduced semantics, stale documentation, or an untested target-specific lowering path.
When examples or internals docs use ARM64 snippets for readability, treat them as examples only. Implementation work must keep the target-aware ABI/runtime boundaries authoritative.
Build & run
cargo build # dev build
cargo build --release # optimized build
cargo run -- file.php # compile a PHP fileThe compiler outputs a native binary next to the source file (e.g., file.php → file).
Test policy
Every feature must have tests before it's considered done. The test suite is the primary quality gate.
Local agent runs should stay focused. By default, run only the build checks and tests needed to validate the implementation or regression you touched, then rely on CI for the complete matrix. Do not run the full local suite (cargo test, cargo test -- --include-ignored, or full Linux Docker scripts) unless the user explicitly asks for it, you are doing a release/pre-release verification, CI is unavailable for the needed signal, or a broad/high-risk change cannot be validated responsibly with focused tests.
Running tests
cargo test test_fizzbuzz # run a focused test by name
cargo test --test codegen_tests test_name # run a focused end-to-end codegen test
cargo nextest run --profile ci test_name # run a focused nextest test with per-test timeout
cargo test # full local suite; only when explicitly requested/necessary
cargo test -- --include-ignored # ignored tests; only when relevant or requestedLinux target-specific regressions can be checked through the Docker scripts in
scripts/. Prefer filters during implementation; the unfiltered commands run
full Linux suites and should be reserved for explicit requests or genuinely
necessary local target validation.
./scripts/test-linux-x86_64.sh iterable # run tests matching a filter
./scripts/test-linux-arm64.sh test_my_feature # run tests matching a filter
./scripts/test-linux-x86_64.sh # full Linux x86_64 suite; only when necessary/requested
./scripts/test-linux-arm64.sh # full Linux ARM64 suite; only when necessary/requested
./scripts/test-linux-x86_64.sh --rebuild # rebuild the Docker image first
./scripts/test-linux-arm64.sh --rebuild # rebuild the Docker image firstSome tests are marked #[ignore] because they require external libraries (e.g., SDL2) not available in CI. Run ignored tests only when the change touches that surface, during explicit release verification, or when the user asks for them.
Test strategy during development
The full test suite is slow because each codegen test spawns as + ld + runs the binary. CI runs sharded executable codegen tests on macos-aarch64, linux-x86_64, and linux-aarch64, and covers ios-arm64 and ios-sim-arm64 through target-specific compile and emitter tests. Pull Requests run the Linux matrix, the core live database suites and the macOS build; the macOS test matrix, the iOS links and the optional-driver live database suites run in the merge queue, or on the PR with the ci:macos / ci:pdo-live labels (CONTRIBUTING.md § "CI tiers and the merge queue"). Local implementation work should optimize for fast, relevant signal:
- While developing a feature: run only the tests for that feature or regression (
cargo test test_my_feature). - Scope to a single test binary: prefer
cargo test --test codegen_tests <filter>over a barecargo test <filter>. A bare filter rebuilds and links all six test binaries every cycle (~2.5s of wasted link time);--test <binary>links only the one you need. Most codegen work lives incodegen_tests. - For target-sensitive changes: run the smallest relevant macOS/Linux focused tests locally when they materially reduce risk; otherwise let CI provide complete target coverage.
- For broad infrastructure changes: validate the edited workflow/config directly (YAML parse,
cargo metadata,nextestconfig checks, etc.) instead of running unrelated Rust suites. - PHP cross-check: opt in narrowly with
ELEPHC_PHP_CHECK=1 cargo test <filter>when PHP equivalence is the question.
Pre-commit verification
Before committing code changes, run the smallest useful focused tests and hygiene checks that cover the implementation. Do not run full local suites by default; CI is responsible for the complete sharded matrix: the PR tier on every push, every target in the merge queue.
cargo build # for code changes that should compile warning-free
cargo test <feature_or_regression_filter> # or the narrower --test <binary> form
git diff --checkFor docs-only, workflow-only, or configuration-only changes, replace Rust test runs with the relevant syntax/metadata checks. For codegen changes, also verify assembly-comment coverage/alignment for any files you touched. If the change can affect generated assembly, runtime helpers, ABI behavior, linking, ownership/GC, or target-specific libraries, run focused tests for affected supported targets when local evidence is needed; otherwise rely on CI for sharded Linux x86_64/Linux ARM64/macOS ARM64 executable coverage and the target-specific iOS device/Simulator compile and emitter checks. For target-sensitive Darwin work, add the ci:macos label so the macOS tier runs on the PR before it reaches the merge queue.
Test structure
| File | What it tests | How |
tests/lexer_tests.rs | Tokenization | Asserts token sequences from source strings |
tests/parser_tests.rs | AST construction | Asserts AST node structure and operator precedence |
tests/codegen_tests.rs, tests/codegen/ | Full pipeline (end-to-end) | Compiles PHP → binary, runs it, asserts stdout |
tests/error_tests.rs, tests/error_tests/ | Error reporting | Asserts that invalid programs produce the right error messages |
Test coverage requirements
- New language construct (keyword, operator, statement): needs lexer, parser, codegen, AND error tests
- New operator: needs a Pratt parser binding power test verifying precedence relative to adjacent operators
- New statement type: needs at least one codegen test showing correct output, one test for edge cases (empty body, nested), and one error test for malformed syntax
- New built-in function: needs codegen tests for normal use and error test for wrong argument count/types
- Bug fix: must include a regression test that would have caught the bug
- Every feature also needs an example in
examples/. If an existing example can showcase the new feature naturally, update it. Otherwise, create a newexamples/<name>/main.phpwith its own.gitignore(containing*.s,*.o,main). Examples should be small, readable programs that demonstrate real use cases — not just test cases.
Writing codegen tests
Codegen tests compile inline PHP source and assert stdout:
#[test]
fn test_my_feature() {
let out = compile_and_run("<?php echo 1 + 2;");
assert_eq!(out, "3");
}Each test runs in an isolated temp directory. Tests run in parallel — the compile_and_run helper handles isolation automatically.
A fixture that contains an eval() whose source is known only at runtime (the $argc > 0 ? '...' : '' pattern, eval($code)) lowers the whole builtin catalog and costs tens of seconds per compile; a literal eval('...') costs about as much as the same code inline. When several such fixtures only compare stdout, bundle them with assert_bundle / assert_bundle_with (tests/codegen/support/bundle.rs): the cases compile and run as one program, stdout is split per case, and a failure names the case. Cases share one scope and declaration namespace (unique function/class names, no declare(strict_types=1), no exit), so keep a fixture on its own when it needs per-program heap or GC statistics, a non-zero exit, or stderr assertions. Name a bundled test test_<family>_bundle: .config/nextest.toml gives that suffix a longer timeout budget, because one bundle runs every case it replaced.
Architecture
PHP source → Lexer → Parser → Magic constants → Conditional compilation → Resolver
→ NameResolver → Constant folding → Type Checker / warnings → AST optimizer passes
→ EIR lowering / validation → EIR optimization passes → EIR codegen → runtime cache
→ assembler/linker → binaryBackend policy
EIR is the active backend for compiler implementation work. New language features, builtins, runtime semantics, optimizer behavior, ownership paths, and target support must be implemented through the AST → EIR → EIR codegen path.
Shared target/runtime infrastructure remains active under src/codegen_support/,
including ABI helpers, runtime emitters, runtime data emission, target
convention helpers, and linking support. New PHP-visible behavior must be driven
by EIR lowering and src/codegen/.
Key modules
| Module | Entry point | Responsibility |
src/pipeline.rs | compile() | Orchestrates frontend passes, type checking, optimization, runtime cache, codegen, and linking |
src/lexer/ | tokenize() | Source → Vec<(Token, Span)> |
src/parser/ | parse() | Tokens → Program (Vec of Stmt). Pratt parser for expressions |
src/magic_constants/, src/magic_constants.rs | substitute_file_and_scope_constants() | Lowers PHP magic constants before resolver/name-resolver/optimizer passes |
src/conditional.rs | apply() | Applies compiler ifdef conditional branches |
src/resolver/ | resolve() | Resolves include/require, discovers declarations, and tracks include-loaded function variants. Runs before namespace/name canonicalization |
src/name_resolver/ | resolve() | Applies namespace/use rules, rewrites references to canonical fully-qualified names, handles PHP-style builtin fallback, and flattens namespace-only AST nodes before type checking |
src/types/ | check() | Type checking, returns CheckResult with TypeEnv, function/class/interface/enum/FFI metadata, warnings, required libraries, and the internal Mixed type for heterogeneous assoc-array values |
src/optimize/, src/optimize.rs | fold_constants(), propagate_constants(), eliminate_dead_code() | AST-level constant folding/propagation, control-flow pruning/normalization, DCE, and effect modeling |
src/ir/ | IR types / builders / validator | EIR program, function, block, instruction, terminator, value, local, effect, ownership, and textual-format definitions |
src/ir_lower/ | lower_program() | Active AST → EIR lowering, including local slot creation, hidden temporaries, ownership annotations, and PHP call semantics |
src/ir_passes/ | optimize_module() / allocate_registers() | EIR analyses and transformations over lowered functions: fixed-point optimization pass driver (identity folding, …; gated by --ir-opt) and linear-scan register allocation, before codegen |
src/codegen/ | generate() | Active EIR → target assembly backend |
src/codegen_support/abi/ | ABI helpers | Target-specific argument materialization, frame layout, registers, stack slots, symbols, and call helpers |
src/codegen_support/runtime/ | Runtime emitters | Shared __rt_* helpers and runtime data used by generated programs |
src/codegen/program_usage/ | Program scans | Collects codegen metadata such as required classes and variables before emission |
src/runtime_cache.rs | prepare_runtime_object() | Builds/reuses the target runtime object before final linking |
src/errors/ | report() | Error formatting with line:col |
src/span.rs | Span | Source position (line, col) attached to all AST nodes |
Bridge crates and --with-<crate> flags
Optional, heavyweight functionality that is naturally expressed with Rust
libraries lives in a workspace crate under crates/elephc-<name>/, compiled as a
staticlib and linked into the user program on demand. Every such bridge is
described by one entry in the BRIDGES table in src/linker/bridges.rs; link() and the
discovery/auto-build helpers are fully table-driven, so adding a bridge is a
single table entry rather than new linker logic.
Every bridge entry must declare a reviewed MonitoringPolicy. Use generic
timing when ordinary function timing is sufficient, typed I/O policy when the
bridge performs database or network work, and an infrastructure policy with a
nonempty reason only for monitor/runtime plumbing. The bridge-catalog gate
rejects an unspecified policy. Bridge-backed RuntimeFnId values must also
declare their operation policy, and any runtime operation carrying
BLOCKING_IO or NETWORK_IO effects must use evented monitoring. I/O bridges
share the dependency-neutral elephc-monitoring-contract helper and distinct
database/network runtime slots, which must remain dormant outside an active
capture.
A bridge is linked when its lib_name appears in extra_link_libs. That set is
populated automatically by feature detection: the type checker records a needed
library via require_builtin_library(...) (e.g. hash builtins → elephc_crypto)
or through an injected extern "elephc_<name>" { ... } prelude block (e.g. the
PDO/tz/image preludes), so programs that do not use a feature never link its
bridge.
--with-<crate> force-enables a bridge regardless of detection. It force-links
the staticlib (whole-archived so it is not dead-stripped) and, for crates whose
PHP surface comes from a prelude (pdo, tz, image, xml), force-injects that
prelude so the API is declared even when usage was not detected. The flag name is
the bridge's flag_name (crate_name minus the elephc- prefix): --with-pdo,
--with-tls, --with-crypto, --with-iconv, --with-phar, --with-tz,
--with-image, --with-curl, --with-xml.
--with-web is an alias for --web (the full server mode, which owns the program
entry point). An unknown --with-<name> is a hard CLI error listing the valid
crates. The end-to-end wiring is CLI (src/cli.rs, with_crates) → pipeline
(src/pipeline.rs: force-link + prelude forcing) → linker
(src/linker.rs: forced_whole_archive).
--with-regex is the explicit non-bridge exception: it enables
RuntimeFeatures::regex for opaque dynamic eval source, resolves the managed
pcre2 package, and registers its shim callbacks with Magician. Static regex
use still auto-detects the same feature. Merely declaring pcre2 does not link
it, and dynamic eval without the capability leaves regex builtins unavailable
at runtime.
curl is the first ordinary bridge crate that ALSO needs a managed native
package: --with-curl (or ordinary detection
of a curl_* call, via src/curl_prelude/detect.rs) force-links elephc_curl, and src/pipeline/backend.rs
mirrors that into NativeRequirement::package("curl") (from the
BRIDGE_NATIVE_PACKAGES table in src/linker/native_packages.rs) so the final link also
resolves the managed curl package's libcurl.a, plus the openssl/zlib
archives it declares as dependencies, in the fixed order
libcurl.a -> libssl.a -> libcrypto.a -> libz.a. There is no system fallback:
a missing curl package fails closed with the same elephc native add curl
recovery style as PCRE2, never a -lcurl.
xml is the second such bridge: --with-xml (or ordinary detection of an
xml_* / xmlwriter_* call or of the XMLParser / XMLWriter classes, via
src/xml_prelude/detect.rs) force-links elephc_xml, and src/pipeline/backend.rs
mirrors that into NativeRequirement::package("libxml2") so the final link also
resolves the managed libxml2 package's two archives in the fixed order
libelephc_libxml2_shim.a -> libxml2.a. The bridge reaches libxml2 only through
the recipe-compiled shim src/native_deps/recipes/libxml2_shim.c (which owns
every access to libxml2's struct internals) plus libxml2's opaque public API,
both declared in crates/elephc-xml/src/ffi.rs without linking — no bindgen,
no -sys crate, no headers at cargo build time. Apple targets add -liconv
for libxml2's encoding handlers through the entry's
BridgeStaticlib.apple_libraries; glibc provides iconv from libc. libxml2
has no catalog dependencies, and a missing package fails closed with the
elephc native add libxml2 recovery, never a -lxml2.
gmp, fileinfo (libmagic) and openssl (openssl-crypto) follow the same
shape. Every bridge-to-package pairing lives only in BRIDGE_NATIVE_PACKAGES,
whose order is the final link order; --print-capabilities reports each pairing
as a native:<package> field, and the release probe adds exactly those packages,
so a new pairing needs no edit in scripts/verify-release-artifact.sh.
Codegen layout
src/ir_lower/is the active high-level lowering layer. Add PHP-visible semantics there.src/codegen/lower_inst/andsrc/codegen/lower_term.rsare the active EIR instruction/terminator assembly lowerers.src/codegen/context.rs,src/codegen/frame.rs, andsrc/codegen/value_placement.rscarry active backend state, frame layout, and value placement.src/codegen_support/runtime/mod.rsemits shared runtime code (__rt_*routines)src/codegen_support/runtime/data/emits shared runtime.data/.bsssymbols and metadata tablessrc/codegen_support/abi/centralizes target-specific register, stack, frame, symbol, and call mechanics. Prefer these helpers over hardcoding ARM64 or x86_64 details in feature emitters.
Adding a new operator
Key invariants:
- A new operator touches the whole pipeline: token (
src/lexer/), Pratt binding power (infix_bp()insrc/parser/expr/pratt.rs),BinOpvariant (src/parser/ast.rs), type inference (src/types/checker/, usuallyinference/ops.rs), optimizer folding/effects (src/optimize/), and EIR lowering (src/ir_lower/expr/+src/codegen/lower_inst/). - Precedence and associativity must match PHP; keep folds PHP-equivalent and cross-check edge cases with
php -r. - Needs a Pratt binding-power test asserting precedence relative to adjacent operators, plus tests in all 4 test files (lexer, parser, codegen, error).
Adding a new statement type
Key invariants:
- A new statement touches parser (
StmtKindinsrc/parser/ast.rs+src/parser/stmt.rs), resolver/name-resolver (if it holds names, declarations, includes, function variants, or expressions), type checker (src/types/checker/), optimizer/effects/warnings (src/optimize/), and EIR lowering (src/ir_lower/stmt/+src/codegen/). - If it introduces variables or hidden temporaries, update EIR local/temp declaration in
src/ir_lower/context.rsand frame-layout allocation before frame sizing. - Also audit every AST-walking pass (see "Adding or changing an AST node") — a missed pass usually causes silent miscompilation rather than a compile error.
Adding or changing an AST node
When adding a new ExprKind or StmtKind, check every AST-walking pass. The compiler has many passes that deliberately recurse by variant, and missing one usually creates silent miscompilation rather than a compile error.
Common places to audit:
- Parser construction and lowering in
src/parser/ - Resolver/include discovery and function-variant handling in
src/resolver/ - Namespace/use/FQN rewriting in
src/name_resolver/ - Type checking, inference, return analysis, warnings, and type compatibility in
src/types/ - Optimizer folding, propagation, DCE, control-flow normalization, and effect modeling in
src/optimize/ - Program usage scans in
src/codegen/program_usage/ - Local/hidden-slot declaration in
src/ir_lower/context.rsand frame placement insrc/codegen/ - Ownership metadata in
src/ir_lower/ownership.rs, EIR ownership lowering insrc/codegen/lower_inst/ownership.rs, and related runtime/GC paths - EIR lowering in
src/ir_lower/plus EIR backend lowering insrc/codegen/ - Lexer/parser/codegen/error/regression tests, depending on the surface area
Adding a new built-in function
PHP builtin surfaces are declared once in the dependency-neutral
crates/elephc-builtin-contract catalog. The compiler home file at
src/builtins/<area>/<name>.rs joins AOT semantics with builtin!; Magician's home
file joins eval hooks with eval_builtin!. Both bindings key on the same stable
BuiltinId. Name lookup, FunctionSig, strict-PHP visibility, backend support, and
generated docs derive from that contract. Do not
re-add builtin names to hand-maintained tables (catalog.rs, signatures.rs, per-area
check_builtin arms) — they are superseded by the registry.
Key invariants:
- One contract, one home file per backend. The neutral contract owns PHP-visible
names, parameters, defaults, passing modes, arity, returns, visibility,
requirements, and docs. The AOT home file repeats none of that metadata; its
mandatory
semantics:descriptor owns validation, result typing, effects, ownership/aliasing, requirements, target strategy/support, runtime-function inventory, argument lowering, callable policy, and backend-neutral EIR lowering. Home files must not importcrate::codegen. - Use typed EIR targets. Most builtins use
runtime_fn_semantics(RuntimeFnId::...); reusable predicates usetype_predicate_semantics(...); a genuinely compositional builtin may provide aBuiltinLowering::Eirhook that emits EIR primitives/graphs throughBuiltinLoweringContext. PHP names never select backend emitters. - Result types are shared contracts.
BuiltinResultType::Declared,Checked, orSharedis authoritative for checker/EIR agreement. Acheckhook is embedded into the descriptor automatically; use a shared result resolver when the backend storage representation differs from the checker-facing PHP type. - Ownership belongs in the semantic descriptor. Choose
NonHeap,Fresh,Borrowed,Independent,Aliases, orMayAliasArgumentsaccording to the actual returned storage. Cleanup, optimizer, and callable consumers read that same contract. - Backend implementation stays typed and target-aware. Add or extend a
RuntimeFnId/RuntimeCallTargetimplementation undersrc/codegen/lower_inst/runtime_functions/orruntime_calls.rs, plus any shared__rt_*routine undersrc/codegen_support/runtime/. Every supported target must be handled by that typed backend path. isset/unset/empty/exit/dieare language constructs that stay checker-resident (numeric/arrayscheck_builtin), not in the registry.buffer_newis catalog-name-only (its call form is dedicated syntax);buffer_lenandbuffer_freeare ordinary registry builtins.- elephc-only contracts declare
extension: trueso--strict-phphides them from AOT and eval without a second pinned list. Injected preludes must callinternal: true__elephc_*aliases instead of PHP-visible extension builtins; a parity gate scans the prelude sources to enforce this. - Add codegen + error tests (include a case-insensitive or namespaced call for PHP-visible builtins); keep shared-contract/backend-support parity gates green.
- Magician bindings contribute only
contract,area, and direct/value hooks. UseRuntimeBuiltinIdplus the versioned boxed-cell C ABI when runtime helper semantics agree, remove superseded hooks, and retain documented adapters only for by-ref/lvalue, callable, reflection, resource, eval-declaration, or strict signature-subset behavior. - Before opening a PR that adds, removes, or changes PHP-visible builtins, run the
update-builtin-docsskill or the equivalent CI sequence:cargo build --example gen_builtins --features curl,python3 scripts/docs/extract_builtins.py --render --force,python3 scripts/docs/audit_builtins.py, andpython3 scripts/docs/elephc_builtins/validate_site_compat.py, pluspython3 scripts/audit_builtin_eir_boundary.py --enforce-target-architecture. Commit the generated docs and registry.
Adding a new EIR optimization pass
IR-level transformations run after EIR lowering/validation through a fixed-point driver (src/ir_passes/), not in the AST optimizer.
Key invariants:
- Implement the
IrPasstrait insrc/ir_passes/<pass>.rsand register it indefault_passes()(src/ir_passes/driver.rs); the driver re-runs the whole set per function to a fixed point, capped byMAX_PASS_ITERATIONS. - Reuse
src/ir_passes/rewrite.rs(RAUW viareplace_all_uses+ shared fold helpers) instead of re-walking operands/terminators. Keep rewrites dominance-safe and PHP-equivalent; cross-check edge cases (division by zero, signed-zero/NaNfloats) withphp -r. - Debug/test builds re-validate each function after every pass and panic (naming the pass) on malformed IR or non-convergence; both guards compile out of
--release. - Behavior must be identical with
--ir-opton or off except for performance (gate:--ir-opt=on|off/--no-ir-opt, envELEPHC_IR_OPT, default on). Add unit tests (src/ir_passes/tests/) and e2e tests (tests/codegen/optimizer/) using runtime-unknown values (e.g.$argc) so the construct survives AST folding.
Call argument semantics
All function-like call surfaces must share the same argument rules instead of normalizing locally in individual emitters:
- Shared named/positional/spread semantics live in
src/types/call_args/whenever they are not codegen-specific. src/types/call_args/owns the semantic planner (CallArgPlan/plan_call_args). The checker and EIR lowering should consume that plan; they should not rebuild named-argument matching, duplicate detection, static associative-spread expansion, spread bounds, or the regular/variadic split locally.- If a codegen surface uses an internal signature with hidden parameters, such as closure captures, pass the caller-visible regular parameter count through
plan_call_args_with_regular_param_count()instead of letting the planner infer it from the full internal signature. - Type-checker validation and diagnostic mapping lives in
src/types/checker/functions/call_validation.rs; it maps planner errors toCompileErrordiagnostics instead of owning the semantic rules. src/ir_lower/expr/owns active EIR call-argument lowering: planner consumption, source-order named/spread lowering, spread checks, and hidden-temp creation.src/codegen/then materializes the lowered call through target-aware ABI helpers.- User-defined calls, builtins, and extern calls must use the same named/spread normalization rules before any callee-specific lowering runs.
- PHP call unpacking with static string keys maps to named arguments (
f(...["a" => 1])behaves likef(a: 1)). Static numeric keys remain positional, and duplicate static string keys inside one unpack use PHP's last-wins behavior before planning. - When adding or extending a builtin, verify
first_class_callable_builtin_sig()as well as the direct builtin signature so first-class callable syntax and callable aliases stay coherent. - PHP source evaluation order is distinct from ABI parameter order. Preserve side effects in source order, then materialize arguments in parameter/ABI order; extern calls follow the same rule before C ABI register loading.
- Spread arguments before named arguments must be evaluated once, length/overwrite checks must happen at the PHP-observable point, and later named-argument side effects must not be skipped by early codegen checks. Too-short spreads for required parameters must fail instead of reading past the array payload.
- A positional spread into a variadic callee fills visible regular parameters first; only the remaining tail becomes the variadic array.
- User-defined variadics accept unknown named arguments as string-keyed variadic entries; internal/builtin variadics reject unknown named arguments like PHP internals.
- Ref-like parameters, including mutating builtin parameters, must avoid value-temp preevaluation so the original storage is passed/mutated.
- If hidden named-argument temporaries are introduced, update
src/ir_lower/context.rsand EIR frame placement so slots are allocated before frame-size calculation.
Optimizer and effects
The optimizer assumes side effects are modeled conservatively. When changing calls, operators, expressions, statements, or builtins:
- Update
src/optimize/effects/if purity, variable reads/writes, call effects, filesystem/runtime state, exceptions/fatals, or by-ref mutation behavior changes. - Do not mark a call as pure if it can read or write globals, files, environment, runtime heap state, object properties, array contents, argument storage, or can emit visible output.
- Keep constant folding in
src/optimize/fold/limited to PHP-equivalent results. If PHP behavior is edge-case sensitive, cross-check withphp -r. - Add optimizer regression tests under
tests/codegen/optimizer/when DCE, constant propagation, control-flow pruning, or folding can observe the change. - Magic constants must be lowered before optimizer passes. Do not introduce optimizer paths that expect raw
ExprKind::MagicConstant. src/optimize/is the AST optimizer only. IR-level (EIR) transformations live insrc/ir_passes/behind the fixed-point pass driver; see "Adding a new EIR optimization pass". Folds that need value identity, basic blocks, or dominance belong there, not insrc/optimize/.
Runtime ownership, GC, and COW
Refcounted runtime values are not plain scalars. When changing arrays, strings, objects, Mixed, Iterable, call returns, or temporaries:
- Preserve the boxed
Mixedcell contract: the runtime tag and payload shape must stay consistent across codegen and runtime helpers. - Respect copy-on-write before mutating arrays or hashes. Use the existing ensure-unique helpers instead of mutating shared storage directly.
- Track whether a value is owned, borrowed, persistent, or a temporary result. Release only values this code path owns.
- Keep cleanup paths balanced across normal returns, early exits, throws/fatals, and control-flow merges.
- Add focused tests in
tests/codegen/runtime_gc/for ownership, aliasing, cycles, heap debug, stack args, and COW changes.
File size policy
As a general rule, aim to keep source files under 500 lines of code. This is a maintainability guideline, not a blind numeric rule.
The real goal is to avoid files that become hard to reason about because they mix multiple responsibilities. In practice:
- Dispatcher/orchestration files (
mod.rs, top-level drivers, large checker/codegen coordinators) should stay slim. If they grow large, split them aggressively. - Multi-responsibility files should be split once they start accumulating unrelated concerns, even if the line count is not yet extreme.
- Leaf files that implement one cohesive feature are allowed to exceed 500 lines when splitting them would create artificial fragmentation.
Examples of files that may reasonably stay above the soft limit:
- a single runtime emitter implementing one substantial builtin or runtime routine
- a single compiler pass file that is still clearly about one feature and one code path
- a self-contained parser/lowering/runtime leaf where splitting would only spread one mental model across several tiny files
Examples of files that should usually be split:
- a file that mixes dispatch, validation, data collection, and post-processing
- a file that contains several unrelated builtins or runtime helpers
- a file that acts as a “miscellaneous bucket” for code that did not get a home
So the policy is:
- treat 500 LOC as a warning sign
- treat mixed responsibilities as the real trigger for refactoring
- do not split a file that owns one coherent feature just to satisfy the number
In short: prefer cohesion over mechanical line-count compliance. A 650-line mono-feature leaf is acceptable; a 350-line multi-purpose orchestrator is already a refactor candidate.
Rust module preamble policy
Every repo-owned Rust source file (*.rs) must start with a module-level Rustdoc preamble before any use, mod, item, or test helper code. Use //! comments so the explanation is attached to the module in rustdoc.
The preamble is mandatory for all new Rust files and must be added or preserved when touching existing Rust files. Release verification should report any Rust file that is missing it.
Standard format:
//! Purpose:
//! Explain what this file owns in 2-4 lines.
//!
//! Called from:
//! - `crate::path::caller()` or the relevant test/module entry point.
//!
//! Key details:
//! - Important invariants, ordering constraints, ownership/ABI/runtime rules, or coupling.For test files, use the same structure but describe the test surface instead of production callers:
//! Purpose:
//! Integration or regression tests for the relevant feature area.
//!
//! Called from:
//! - `cargo test` through Rust's test harness.
//!
//! Key details:
//! - Fixture layout, platform assumptions, ignored-test requirements, or why edge cases exist.Keep preambles concise and factual. Do not include refactor history, stale line numbers, or broad architecture prose that belongs in docs/internals/.
Rust function docblock policy
Every explicit Rust function in repo-owned Rust source files must have a concise docblock explaining what that function does. This applies equally to public functions, restricted-visibility functions (pub(crate), pub(super), etc.), private helper functions, impl methods, trait methods, and test functions.
Use /// Rustdoc comments immediately before the function item or its item attributes. Keep the docblock specific to the function's actual responsibility, inputs, outputs, side effects, ownership/ABI/runtime constraints, and failure behavior when those details matter. Do not use vague filler such as "handles logic" or "processes data".
When documenting test functions, describe the behavior or regression being verified and any important fixture/platform assumptions. For explanatory comments inside a function body, use normal // comments, not ///; Rustdoc comments inside function bodies produce warnings or errors because they do not document an item.
Adding or updating function docblocks must not change code behavior. Do not alter function signatures, visibility, attributes, derives, module declarations, control-flow braces, strings, assembly instructions, or instruction-comment alignment while adding documentation. If a doc-only change causes cargo check, cargo check --tests, or git diff --check to fail, fix the documentation placement or comment style rather than changing code to fit the comment.
Codegen conventions (target-aware)
- Prefer helpers from
src/codegen_support/abi/for registers, stack slots, frame layout, argument materialization, symbol addresses, and calls. - New feature emitters belong in
src/codegen/. - New feature emitters must support every supported target through
emitter.targetor clearly isolate target-specific code behind existing target helpers with explicit tests and diagnostics. - Avoid hardcoding ARM64 register names, x86_64 register names, syscall numbers, object formats, or stack alignment rules in shared lowering code.
- Do not add an ARM64-only runtime helper, builtin emitter, ABI path, or ownership cleanup path unless the feature is intentionally target-gated and documented as unsupported elsewhere.
- Target-sensitive changes need coverage for every supported target they can affect. During local implementation, run focused target checks only when they are needed for confidence; rely on CI for the complete supported-target matrix unless the user requests local Docker runs or CI cannot provide the needed signal.
ARM64 quick reference
- Integers: result in
x0 - Floats: result in
d0 - Strings: pointer in
x1, length inx2 - Function args:
x0-x7(int = 1 reg, string = 2 regs),d0-d7(floats) - Return value: same as expression result (
x0,d0, orx1/x2) - Stack frame:
x29= frame pointer,x30= link register, locals at negative offsets fromx29 - ABI helpers:
src/codegen_support/abi/centralizes load/store/write per type - Labels: use
ctx.next_label("prefix")— global counter prevents collisions across functions - Mixed values:
PhpType::Mixedis an internal boxed runtime shape used for heterogeneous associative-array values; codegen/runtime must preserve the boxed cell contract instead of treating it like a plain scalar
Assembly comment policy
Every emitter.instruction(...) call must have an inline // comment aligned to
column 81, with // -- description -- block comments before related groups.
Examples
Each example lives in examples/<name>/main.php with its own .gitignore. To run:
cargo run -- examples/fizzbuzz/main.php
./examples/fizzbuzz/mainPHP compatibility
PHP-derived syntax must be 100% compatible with PHP. When elephc implements a PHP construct (variables, operators, keywords, built-ins), it must behave identically to PHP. This means:
- Variable names, keywords, operators, and built-in function names must match PHP exactly
- Superglobals (
$argc,$argv) must use PHP's syntax (e.g.,$argv[0], notargv(0)) - Operator precedence and associativity must match PHP
- String escape sequences must match PHP behavior
- Built-in function signatures must match PHP (argument count, order, types)
When in doubt, test with php -r '...' to verify behavior.
elephc also provides compiler-specific extensions beyond standard PHP (e.g., ptr, extern, buffer<T>, packed class). These features have no PHP equivalent and are not expected to run under the PHP interpreter. They are clearly distinguishable from PHP syntax and exist to enable use cases (FFI, game development, low-level memory access) that PHP cannot address.
Documentation
The docs/ directory is the project's complete documentation, organized into the following sections:
docs/
├── README.md # Main index
├── getting-started/ # Installation and first program
│ ├── installation.md
│ └── your-first-program.md
├── compiling/ # The compiler CLI: flags and the full compilation process
│ ├── overview.md
│ ├── compilation-pipeline.md
│ ├── cli-reference.md
│ ├── targets.md
│ ├── optimization.md
│ ├── output-and-diagnostics.md
│ └── linking-and-conditional-compilation.md
├── php/ # PHP syntax (standard PHP features)
│ ├── types.md
│ ├── operators.md
│ ├── control-structures.md
│ ├── functions.md
│ ├── strings.md
│ ├── arrays.md
│ ├── math.md
│ ├── classes.md
│ ├── namespaces.md
│ ├── magic-constants.md
│ └── system-and-io.md
├── beyond-php/ # Compiler extensions (not valid PHP)
│ ├── pointers.md
│ ├── buffers.md
│ ├── packed-classes.md
│ ├── extern.md
│ └── ifdef.md
└── internals/ # Compiler internals
├── what-is-a-compiler.md
├── how-elephc-works.md
├── the-lexer.md
├── the-parser.md
├── the-type-checker.md
├── the-optimizer.md
├── the-codegen.md
├── the-runtime.md
├── memory-model.md
├── architecture.md
├── arm64-assembly.md
└── arm64-instructions.mdAstro compatibility
All docs files are Markdown with YAML frontmatter compatible with Astro content collections. Every .md file must have title and description. Handwritten pages and generated area indexes also require sidebar.order, following this format:
---
title: "Page Title"
description: "One-line description of the page."
sidebar:
order: N
---titlereplaces the# Heading— do not add a top-level# Titlein the body (Astro renders it from frontmatter)sidebar.ordercontrols page ordering within its section. Generated per-builtin pages omit it and use the site's alphabetical ordering, so adding a builtin does not renumber unrelated pages.- No navigation links (
[← Back],Next:, etc.) — Astro handles navigation - Use standard Markdown (CommonMark). No custom shortcodes or Astro components inside docs
Keeping docs up to date
Documentation must be kept up to date. When adding a new feature:
- PHP syntax feature (operator, built-in, statement, etc.) → update the relevant page in
docs/php/. Add the function signature, parameters, return type, and a short example. For builtins, prefer the generated-docs path: run theupdate-builtin-docsskill before opening a PR, or run the manual builtins docs sequence above. Commit updates underdocs/php/builtins*,docs/internals/builtins/, andscripts/docs/builtin_registry.json. - Compiler extension (pointer, buffer, extern, ifdef) → update the relevant page in
docs/beyond-php/. - Compiler internals change (pipeline, type checker, optimizer, codegen, runtime, ABI, memory model) → update the relevant page in
docs/internals/. - Compilation flow or CLI change (new/changed flag, env var, pipeline phase, target, output mode) → update the relevant page in
docs/compiling/, keepingdocs/compiling/cli-reference.mdauthoritative and in sync withsrc/cli.rs. Mirror user-facing flag examples inREADME.md. - If a feature was previously listed as "not supported", remove that note.
- If there are known incompatibilities with PHP, document them in
docs/php/types.md(incompatibilities section). - Update
docs/README.mdindex if adding a new page.
Roadmap management
ROADMAP.md tracks planned work, organized by version. Per-version delivered work belongs in CHANGELOG.md, not the roadmap.
- Add planned work as
[ ]under the version where it is expected to ship. - When an implementation lands, mark its existing item
[x]. - During release preparation, reconcile the current version section with the audited release range by marking planned items that shipped
[x]. Do not add a roadmap item for work that was not planned; record that delivery inCHANGELOG.md. - Never remove completed items from a version section. Leave them under the version that delivered them so the roadmap preserves that version's scope.
- When all items in a version are completed, the version is considered done; do not move items elsewhere.
Changelog management
CHANGELOG.md is a release artifact, not a development log. It records every released version, newest first, in Keep a Changelog style. Per-version delivered work belongs here, including work that was not planned on the roadmap.
- Do not edit
CHANGELOG.mdduring ordinary development. Feature, fix, refactor, documentation, dependency, and maintenance Pull Requests must not add entries under[Unreleased]or a numbered release. - Keep
[Unreleased]empty between releases. Do not accumulate one changelog bullet per Pull Request. - Only an explicit release-preparation task may add, move, rewrite, or remove changelog bullets.
- Before cutting a release, run the
prepare-release-changelogskill. It must reconcile every merged Pull Request and direct commit since the latest published release against the exact candidate SHA, then write the approved user-facing bullets directly under the numbered candidate release section. An incomplete source ledger or unresolved commit is a release blocker. - If the target version is not known, stop and ask for it before editing the changelog. Do not use
[Unreleased]as a staging section for a known release.
When cutting a release:
Add a new section at the top (under the header), above the previous version:
## [X.Y.Z] - YYYY-MM-DD - One terse, user-facing bullet per notable change.Keep entries concise (usually one or two bullets), describe what shipped — not the implementation — and use the absolute release date.
Add a matching compare link at the bottom of the file, also newest first, immediately above the previous version's link:
[X.Y.Z]: https://github.com/illegalstudio/elephc/compare/v<previous>...vX.Y.ZThe first-ever release uses the
releases/tag/v0.1.0form instead of a compare range. Every version section must have its link; do not leave the link out.- Never change elephc's Cargo package version in
Cargo.tomlorCargo.lock. Release automation in CI owns Cargo version bumps; agent changes should leave those files' version numbers untouched unless the user explicitly overrides this policy.
Conventions
- No
Co-Authored-Bylines in commits - Use commit message prefixes such as
feat:,fix:,chore:,docs:,refactor:, ortest: - Keep commit messages concise
- Run the focused pre-commit verification above before committing code changes. Do not knowingly commit with relevant focused tests failing; the full suite must pass in CI.
- Zero compiler warnings policy (
cargo buildmust be clean) - Never run
cargo fmtin this repo. Use targeted manual edits only; global formatting creates noisy churn here. - Bridge C-ABI return buffers are never process-global: a
static Mutex<CString>handing outas_ptr()is a cross-thread use-after-free (the next call frees what an earlier caller is still reading). Usethread_local!cells or caller-owned allocations. Enforced bytests/ffi_buffer_hygiene.rs; rationale in CONTRIBUTING.md § "Expose a stable C ABI".
Cursor Cloud specific instructions
The dependency-refresh update script runs cargo build on startup, which also
materializes the bridge libelephc_*.a staticlibs that linker.rs links into
compiled programs. Standard build/test/run commands are documented above and in
CONTRIBUTING.md; the notes below are only the non-obvious cloud caveats.
- Rust toolchain is version-pinned. CI pins Rust
1.98in.github/docker/ci.Dockerfile(RUST_VERSION) and workflowRUST_TOOLCHAIN. The "no warnings" gate greps build output, so use that pin rather than a floatingstable. The cloud VM's rustup default may lag that pin until the environment image is rebuilt. - The compiler shells out to the host
as+gcc/ld. These are present. On Linux, compiling a program prints benign linker warnings (gethostbynamein statically linked apps, missing.note.GNU-stack, executable stack). They are not errors — the produced binary runs fine. phpis not installed. PHP-equivalence cross-check tests (ELEPHC_PHP_CHECK=1 cargo test ...) will not run without installing a PHP interpreter first. Normal codegen tests do not need it.- Docker-based Linux cross-test scripts are unavailable by default.
scripts/test-linux-x86_64.sh/-arm64.shrequire Docker, which is not set up here. Rely on hostcargo test/cargo nextest(this VM islinux-x86_64) and CI for the full target matrix. - Quick end-to-end sanity check:
cargo run -- examples/fizzbuzz/main.phpthen./examples/fizzbuzz/main.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

