First of all, thank you for considering contributing to Elephc! ❤️
Every contribution matters, whether it's fixing a typo, improving documentation, reporting a bug, or implementing a new feature.
If you're planning to work on a significant feature or architectural change, please open an issue first so we can discuss the design before implementation.
This helps avoid duplicated work and ensures that the proposed solution aligns with the project's long-term direction.
Contributions created with the help of AI tools are welcome. What matters to us is the quality and correctness of the result, not how it was produced — so the usual expectations still apply: the code must build, be covered by tests, follow the surrounding style, and come with a clear description. Please review anything an AI helps you write as carefully as you would your own work, since you remain responsible for whatever you submit.
If you're working toward something bigger than a single, self-contained change, we recommend writing a plan before you dive into the code. Plans live in the .plans directory of the repository, and every plan in .plans must be written in English.
Start each plan with a checklist of the tasks it involves, then follow it with the detailed implementation notes for each of them. Keeping the task list up front makes the plan's progress easy to verify at a glance — whether it's complete is simply a matter of checking which tasks are marked done.
Leave your plan in the repository until the work it describes is entirely finished. You don't have to land everything in one Pull Request: a plan may be split across several PRs, as long as the test suite stays green at every step. Once a plan is complete, the maintainers will clear it out during periodic cleanups, so there's no need to remove it yourself.
When reporting a bug, please include as much information as possible:
- Operating system
- CPU architecture
- Elephc version
- Steps to reproduce
- Expected behavior
- Actual behavior
- Relevant code snippets or logs
A minimal reproducible example is always appreciated.
Do your work on a dedicated branch — one branch per feature or fix — and prefer a
separate git worktree per branch so your changes stay isolated from main and
from any other work in progress. Worktrees let you keep several branches checked
out at once without stashing or switching back and forth in a single checkout.
Name the branch with a short, descriptive slug behind a type prefix that mirrors the commit-message prefixes used in this project:
feat/— a new featurefix/— a bug fixdocs/— documentation onlyrefactor/— internal restructuring with no behavior changechore/— tooling, CI, or housekeepingtest/— tests only
When the work tracks a GitHub issue, include the issue number, e.g.
fix/369-tier2-range-analysis.
Worktrees can be managed by hand (git worktree add), but a small helper makes it
painless. We recommend ggw, which
creates, navigates, and pushes worktree-backed branches for you:
ggw create feat/my-feature # create the branch and its worktree
ggw cd feat/my-feature # switch into the worktree
# ... implement your change ...
git commit -m "feat: add my feature"
ggw push # push the branch and set its upstreamggw push is equivalent to git push -u <remote> feat/my-feature — use whichever
you prefer. Once the branch is pushed, open your Pull Request as described below.
Before opening a Pull Request, please ensure that:
- Your code builds successfully.
- Existing tests continue to pass.
- New functionality includes tests whenever possible.
- Documentation is updated when appropriate.
- Commits are reasonably organized and have meaningful commit messages.
- When the Pull Request addresses an existing issue, reference it in the description.
Please keep Pull Requests focused and self-contained. A Pull Request that solves a single, well-defined problem is far easier to review than a large one that bundles several unrelated changes together.
Pull Requests are classified automatically by the PR labels workflow. Its
catalog and matching rules live in .github/pr-labels.json, and the classifier
is implemented in .github/scripts/pr-labels.cjs.
The workflow runs when a Pull Request is opened, reopened, updated, edited, or marked ready for review. On each run it recalculates the managed labels and removes managed labels that no longer match. Contributors therefore do not need label-management permissions and should not add or remove labels in the following namespaces by hand:
| Namespace | Meaning | Values |
|---|---|---|
type:* |
The intent of the change. Exactly one is assigned from the conventional PR title, falling back to the branch prefix. | type:feature, type:fix, type:docs, type:refactor, type:chore, type:test, type:triage |
area:* |
The primary compiler or repository components affected by the changed paths. Up to three are assigned. | area:lexer, area:parser, area:resolver, area:types, area:optimizer, area:eir, area:codegen, area:runtime, area:builtins, area:web, area:magician, area:platform, area:tooling-ci, area:docs, area:triage |
target:* |
A target explicitly affected by the title, branch, or changed paths. Target-neutral changes receive no target label. | target:linux-x86_64, target:linux-aarch64, target:macos-aarch64, target:ios-arm64, target:ios-sim-arm64, target:windows-x86_64, target:wasm32-wasi |
size:* |
Review size derived from the number of changed files and total added plus deleted lines. Exactly one is assigned. | size:xs, size:s, size:m, size:l, size:xl |
scope:* |
Additional review attention for unusually broad changes. | scope:multi-area |
The recognized conventional prefixes map directly to type labels: feat: and
feature: become type:feature; fix: becomes type:fix; docs: becomes
type:docs; refactor: becomes type:refactor; chore: becomes type:chore;
and test: or tests: becomes type:test. A title or branch without a
recognized prefix receives type:triage. The title takes precedence over the
branch name, so correcting the title is normally enough to correct the type.
Size labels use the first matching threshold below. Either the file count or the changed-line count can move a Pull Request into the larger category.
| Label | Threshold |
|---|---|
size:xl |
More than 100 files or 10,000 changed lines |
size:l |
More than 30 files or 2,000 changed lines |
size:m |
More than 10 files or 500 changed lines |
size:s |
More than 3 files or 100 changed lines |
size:xs |
Everything smaller |
A target:* label describes target-specific content; it does not add that
target to the supported-target matrix. The supported-target policy in
AGENTS.md remains authoritative.
Some labels require maintainer judgment and are deliberately preserved by the automation:
topic:*describes cross-cutting semantics. The available labels aretopic:php-compat,topic:ownership-gc,topic:performance,topic:abi,topic:arrays,topic:strings,topic:closures,topic:generators,topic:fibers,topic:regex,topic:magic-methods,topic:control-flow,topic:numeric-literals,topic:json, andtopic:errors.priority:highis assigned only by maintainers.
The repository-wide labels bug, enhancement, duplicate, good first issue, help wanted, invalid, question, and wontfix are reserved for
issue triage. GitHub exposes the same label catalog to issues and Pull Requests,
but these labels should not be used in place of the PR type:* labels.
If an automatic label looks wrong, first check the conventional title and the
changed paths. Explain any remaining mismatch in the Pull Request instead of
creating a near-duplicate label; a maintainer can assign an appropriate
topic:* label or adjust the classifier rules. Changes to the label catalog or
classification behavior must update .github/pr-labels.json and the focused
tests, which can be run with:
node --test .github/scripts/pr-labels.test.cjsOpen your Pull Request as a draft while you are still iterating, and keep it in draft until you are confident the implementation is complete and correct.
Once you switch it to ready for review, please leave the Pull Request
untouched — do not push further changes, and do not rebase or merge main into it
to keep it aligned. From that point on the maintainers take over the Pull Request
and will handle reviewing, updating, and integrating it.
Try to follow the style already used throughout the codebase.
Consistency is generally more important than personal preference.
The assembly elephc emits is meant to be read and understood by someone learning
how compilers work, so every emitter.instruction(...) call must carry an inline
// comment explaining what the instruction does — and those comments are aligned
to a fixed column. A few rules keep them consistent:
- Every instruction line gets a comment. No exceptions: if you add an
emitter.instruction(...), it gets a// comment. - The
//starts at column 81. Pad the line with spaces so the//sits at the 81st character (1-indexed). If the code itself already reaches 80 characters or more, put exactly one space before the//. - Group related instructions under a block comment. Put a standalone
// -- description --line before a block of related instructions (e.g.// -- set up stack frame --). - Explain intent, not the mnemonic. Write "store argc from OS", not "store x0 to memory" — the reader can already see the instruction; the comment should say why it's there.
For example:
// -- set up stack frame --
emitter.instruction("sub sp, sp, #32"); // allocate 32 bytes on the stack
emitter.instruction("stp x29, x30, [sp, #16]"); // save frame pointer and return address
emitter.instruction("add x29, sp, #16"); // set new frame pointerTo verify alignment, run ./scripts/check_asm_comments.py against any codegen file you
touch before opening a Pull Request. It flags every emitter.instruction(...) whose
// comment is misaligned and exits non-zero if it finds any, so it also works in a
pre-commit hook or CI:
./scripts/check_asm_comments.py src/codegen/lower_inst/your_file.rsIt accepts multiple files at once, e.g. ./scripts/check_asm_comments.py src/codegen/lower_inst/*.rs.
elephc parses expressions with a Pratt parser, so a new binary operator flows through the whole pipeline — lexer, parser, type checker, optimizer, EIR lowering, and target-aware codegen. Implement it end-to-end:
- Add the token to
src/lexer/token.rs. - Add scanning logic to
src/lexer/scan.rs. - Add the
BinOpvariant tosrc/parser/ast.rs. - Add one line to
infix_bp()insrc/parser/expr/pratt.rs(the Pratt parser binding-power table) so precedence and associativity match PHP. - Add type checking/inference in the relevant
src/types/checker/file, usually underinference/ops.rsor expression inference. - Add optimizer/effect handling when the operator can be folded, propagated,
pruned, or has side effects (
src/optimize/). Keep folds PHP-equivalent — cross-check edge cases withphp -r. - Add EIR lowering in the relevant
src/ir_lower/expr/path and target-aware EIR codegen undersrc/codegen/lower_inst/when the operator needs a new IR instruction or lowering path. - Add tests in all four test files (lexer, parser, codegen, error), including a Pratt binding-power test that asserts precedence relative to adjacent operators.
A new statement kind must be threaded through parsing, the frontend passes, the
type checker, the optimizer, and EIR lowering. Missing a pass usually produces
silent miscompilation rather than a compile error, so also audit every
AST-walking pass (see "Adding or changing an AST node" in CLAUDE.md):
- Add the
StmtKindvariant tosrc/parser/ast.rs. - Add parser logic in
src/parser/stmt.rs. - Add resolver/name-resolver handling if the statement can contain names, declarations, includes, function variants, or expressions.
- Add type checking in the relevant
src/types/checker/module. - Add optimizer/effects/warnings handling if the statement can be folded, pruned,
read variables, write variables, or alter control flow (
src/optimize/). - Add EIR lowering in
src/ir_lower/stmt/and target-aware EIR codegen undersrc/codegen/when the statement needs new instruction or terminator support. - If it introduces variables or hidden temporaries, update EIR local/temp
declaration in
src/ir_lower/context.rsand any frame-layout allocation needed before frame sizing. - Add tests: at least one codegen test showing correct output, one for edge cases (empty body, nested), and one error test for malformed syntax.
IR-level (EIR) transformations run after EIR lowering/validation through a
fixed-point driver, not in the AST optimizer (src/optimize/). Folds that need
value identity, basic blocks, or dominance belong here.
- Implement the
IrPasstrait (name(),run(&mut Function, &mut DataPool) -> bool) in a newsrc/ir_passes/<pass>.rs;runmutates the function in place and returns whether it changed anything. TheDataPoolis the module's shared literal pool for passes that intern new constants (e.g. peephole string-literal concat folding); ignore it (_data) otherwise. - Register the pass in
default_passes()insrc/ir_passes/driver.rs. Order matters: the driver re-runs the whole set per function until none reports a change, capped byMAX_PASS_ITERATIONS. - Reuse
src/ir_passes/rewrite.rsfor value redirection (replace_all_usesfor RAUW) and the shared fold helpers (resolve_chains,neutralize_to_nop,defining_instruction,count_value_uses) 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. - The driver re-validates each function with
validate_functionafter every pass in debug/test builds and panics (naming the pass) on malformed IR or non-convergence; both guards compile out of--release. Rely on this during development. - Add unit tests under
src/ir_passes/tests/(hand-built EIR viacrate::ir::Builder) and end-to-end tests undertests/codegen/optimizer/. In e2e fixtures, use runtime-unknown values (e.g.$argc) so the targeted IR construct survives AST-level folding and actually reaches EIR. - Passes are gated by
--ir-opt=on|off/--no-ir-opt(envELEPHC_IR_OPT), default on. Behavior must be identical with the flag on or off except for performance; verify with--emit-irand--emit-ir --no-ir-opt.
elephc's PHP builtin surface is declared once in the dependency-neutral
crates/elephc-builtin-contract catalog. The compiler home file at
src/builtins/<area>/<name>.rs joins checker/EIR semantics to that contract with
builtin!; Magician's matching home file joins eval hooks with eval_builtin!.
Both bindings are collected through inventory. Name lookup, signatures, defaults,
by-reference/variadic shape, strict-PHP visibility, documentation, backend support,
and optional runtime dispatch therefore come from one stable BuiltinId contract.
Do not re-add builtin names to the old hand-maintained tables (catalog.rs,
signatures.rs, per-area check_builtin arms). They are superseded by the registry;
a builtin is fully wired only when the shared contract and every declared backend
support route validate together.
Add the neutral PHP surface to crates/elephc-builtin-contract/src/catalog_data.rs
(or catalog_surfaces.rs for constructs, dedicated syntax, preludes, and eval-only
surfaces). It owns the canonical name, area/kind, parameters, defaults, passing mode,
arity overrides, return declaration, visibility, requirements, and documentation.
It must not depend on compiler or Magician types.
Then add src/builtins/<area>/<name>.rs and register it in src/builtins/<area>/mod.rs
with pub mod <name>; (keep the list alphabetical). Areas are string, array,
math, io, system, types, callables, spl, pointers (plus internal for
compiler-internal builtins). One builtin per home file; the compiler file owns only
its optional checker hook and AOT semantic descriptor. Start with the mandatory //!
module preamble.
builtin! {
contract: "strlen",
semantics: BuiltinSemantics {
validation: BuiltinValidation::Shared(validate),
result_type: BuiltinResultType::Declared,
effects: BuiltinEffects::Shared(effects),
result_ownership: BuiltinResultOwnership::NonHeap,
requirements: BuiltinRequirements::Static(&[]),
target_strategy: BuiltinTargetStrategy::EirGraph,
target_support: BuiltinTargetSupport::All,
runtime_functions: BuiltinRuntimeFunctions::None,
argument_lowering: BuiltinArgumentLowering::Standard,
callable: BuiltinCallablePolicy::Dynamic(callable_accepts_strlen_source),
lowering: BuiltinLowering::Eir(lower),
},
}Production fields appear in this order: contract, optional check, optional
lazy_check, semantics, optional source-dependent requirements.
contract— canonical lowercase name from the neutral catalog. PHP names never select checker, EIR, or backend emitters after this join.semantics— the complete shared contract for validation, result typing, effects, ownership/aliasing, runtime/link requirements, target strategy/support, typed runtime-function inventory, argument lowering, callable availability, and backend-neutral EIR lowering. For the common case useruntime_fn_semantics(RuntimeFnId::...); for type predicates usetype_predicate_semantics(...); use a customBuiltinSemanticsonly when the builtin composes EIR primitives or needs source/type-dependent contracts.requirements— an optional source-dependent resolver layered over the descriptor's fixed requirements. Do not rediscover bridges or runtime features from PHP names later in the pipeline.- Signature/default/visibility/documentation fields live only on
BuiltinContract.extension: trueandinternal: trueare therefore shared by AOT, eval, strict-PHP, parity tests, and generated docs without a second pinned list.
A builtin whose return type does not depend on its arguments and needs no extra
validation can omit check entirely — the contract's returns field is then
authoritative for the checker.
Add a check hook when the return type depends on argument types/values, or when the
call needs validation beyond arity and the parameter list:
fn check(cx: &mut BuiltinCheckCtx) -> Result<PhpType, CompileError> {
let ty = cx.checker.infer_type(&cx.args[0], cx.env)?;
if !matches!(ty, PhpType::Str | PhpType::Mixed | PhpType::Union(_)) {
return Err(CompileError::new(cx.span, "strlen() argument must be string"));
}
Ok(PhpType::Int)
}The hook receives BuiltinCheckCtx { checker, name, args, span, env } and returns the
call's PhpType (or a diagnostic). Its returned type overrides returns: for the
checker.
For a normal builtin the registry already checks arity and infers every argument once
(for side effects such as variable narrowing and undefined-variable diagnostics)
before calling the hook. Set lazy_check: true when the hook must control
inference order — most importantly when it injects element/parameter type hints into
an unannotated closure argument before that closure is inferred (e.g. usort,
array_map with a callback). With lazy_check: true the hook is responsible for
inferring each argument itself.
The macro embeds a check hook into semantics.validation and changes a declared
result contract to BuiltinResultType::Checked when needed. EIR lowering consumes the
checked call-site type from that same descriptor. If the backend returns a different
storage representation from the checker-facing type (for example, a boxed dynamic
result), set BuiltinResultType::Shared(resolve) and make the resolver return the
actual EIR/backend representation. Do not add PHP-name arms to call_return_type.
The descriptor's lowering field is mandatory. Most builtins lower to one typed
runtime operation:
semantics: runtime_fn_semantics(crate::ir::RuntimeFnId::ArrayMap),The backend receives Op::RuntimeCall with a typed RuntimeCallTarget; PHP names are
not present at that boundary. Add the target to RuntimeFnId (or another typed target
enum), implement it under src/codegen/lower_inst/runtime_functions/ or
runtime_calls.rs, and keep every supported ABI in the same path. If it needs a
runtime routine, add it under src/codegen_support/runtime/<category>/.
For a builtin that is naturally expressed as reusable EIR operations, use
BuiltinLowering::Eir(lower). The hook receives only BuiltinLoweringContext and a
NormalizedBuiltinCall; it may emit typed EIR values/runtime calls, but must not
import crate::codegen, mention physical registers, choose concrete helper symbols,
or emit assembly. BuiltinLowering::TypePredicate is the shared primitive for PHP
type predicates.
Once the shared contract and AOT binding compile, all of the following see the builtin
with no further metadata edits: function_exists() and case-insensitive/namespaced
lookup, the named-argument FunctionSig, checker validation/result typing, optimizer
effects, ownership cleanup, runtime/link requirements, direct and runtime-selected
callable policy, backend-neutral EIR lowering, arity diagnostics, and generated docs.
The registry single-sources the semantic compiler contract. These implementation and user-facing surfaces still need updates when relevant:
-
The typed backend target and any runtime routine it needs. Every target must validate the operand/result contract and support macOS ARM64, iOS device ARM64, iOS Simulator ARM64, Linux ARM64, and Linux x86_64. Use
BuiltinTargetSupport::HostOnlyonly for a builtin that is explicitly refused on iOS with a target-specific diagnostic, such as the process-spawning family. -
The descriptor itself when effects, ownership, requirements, argument lowering, or runtime-callable availability differ from the selected target's defaults. Never mark a call pure if it can read/write globals, files, the environment, heap state, argument storage, or emit output.
-
Examples and generated docs for the PHP-visible surface.
-
Magician support, when dynamic eval must expose the function. Add the matching
crates/elephc-magician/src/interpreter/builtins/<area>/<name>.rsbinding:eval_builtin! { contract: "strlen", area: String, direct: Strlen, values: Strlen, }
The eval macro contributes only area/home-file and dispatch hooks; signature, defaults, by-reference shape, visibility, and docs come from the shared contract. If the builtin is intentionally unavailable in eval, add a typed support reason in
elephc-builtin-contract::supportrather than an allowlist in a parity test. -
Shared boxed-runtime dispatch, when generated runtime helpers already implement equivalent dynamic semantics. Add a stable
RuntimeBuiltinId, target-aware AArch64 and x86_64 C-ABI wrapper arms, then set the Magician binding hooks tonone. Keep a hybrid hook only when the helper covers a strict signature subset (for exampleintvalbase orroundmode), and record its adapter reason in the shared support contract. By-reference/lvalue, callable, reflection, resource, and eval-declaration semantics remain explicit Magician adapters.
- Add codegen tests for normal use (plus at least one case-insensitive or namespaced call for a PHP-visible builtin), and error tests for wrong argument count/types.
- Add or update an example under
examples/when the builtin is a notable user-facing feature. - Document the PHP surface (signature, parameters, return type, a short example) on the
relevant
docs/php/page. - The shared-contract registry, backend support, signature parity, and generated-docs gates must stay green.
A small set of PHP language constructs — isset, unset, empty, exit, die — are
l-value/lazy constructs with dedicated EIR paths and are intentionally kept in the
checker (numeric/arrays check_builtin), not in the registry. Do not migrate those
into builtin!. buffer_new is similar (its call form is dedicated syntax lowered as
ExprKind::BufferNew; only its name lives in the catalog), while buffer_len and
buffer_free are ordinary registry builtins under src/builtins/pointers/.
Builtins that are elephc extensions with no PHP equivalent must declare
extension: true on their shared BuiltinContract so --strict-php hides them from
both AOT and eval.
Injected compiler preludes must never call a PHP-visible extension builtin — use an
internal: true __elephc_* alias instead (see src/builtins/pointers/__elephc_ptr_read_string.rs);
the preludes_never_call_php_visible_extension_builtins gate enforces this.
Magician reads that same flag after joining its binding by BuiltinId; there is no
second extension-name list.
elephc compiles a static subset of PHP straight to native code, so most features
are implemented directly in the compiler (lexer → parser → type checker → EIR →
codegen). But some functionality is heavy, well-served by an existing Rust
library, or simply not worth re-implementing by hand — TLS, PDO database drivers,
image codecs, hashing, timezone tables, Phar archives, the --web server.
If the functionality you want to add can be realized through Rust libraries,
implement it as a bridge crate and register a --with-<crate> flag instead of
hand-writing it in the runtime. A bridge crate is a staticlib under
crates/elephc-<name>/ that elephc links into compiled PHP programs on demand.
The whole linking model is table-driven from the BRIDGES table in
src/linker.rs, so wiring a new bridge is a single table entry plus the PHP-facing
surface.
Follow these technical specifications in full.
Use a bridge crate when the feature (a) maps cleanly onto a maintained Rust crate,
(b) is optional (programs that do not use it must not pay for it), and (c) exposes
a small, stable C ABI surface. Do not use a crate for core language semantics,
ownership/GC, or anything that must be understood line-by-line in the generated
assembly — that belongs in the compiler proper (AGENTS.md/CLAUDE.md).
Create crates/elephc-<name>/ as a workspace member:
# crates/elephc-<name>/Cargo.toml
[package]
name = "elephc-<name>"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
# staticlib: linked into compiled PHP programs. rlib: lets the bridge be
# unit-tested via `cargo test -p elephc-<name>`.
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
# Prefer pure-Rust, musl-friendly crates so the Linux Docker test images link.Add the crate to both members and default-members in the root
Cargo.toml, and an entry under [workspace.dependencies]. Being a default
member is what makes a plain cargo build materialize target/<profile>/libelephc_<name>.a.
Expose a stable C ABI: every entry point is #[no_mangle] pub extern "C" and
must be panic-free across the boundary (catch/encode errors, return error codes or
null; never unwind into generated code). Keep the surface small and explicit —
pass pointers + lengths for strings/buffers, return primitive status values. Name
exports elephc_<name>_* so they are easy to find and namespace-clean.
Every supported target must build and link the crate: macos-aarch64,
ios-arm64, ios-sim-arm64, linux-aarch64, and linux-x86_64. The iOS
artifacts are libraries cross-compiled from macOS. A bridge that only works on
one target is not acceptable (see the supported-target policy in CLAUDE.md).
Add one BridgeStaticlib entry. This is the only linker change required —
discovery, on-demand build, search paths, whole-archiving, and macOS frameworks
are all driven from the table:
BridgeStaticlib {
lib_name: "elephc_<name>", // `-l` name → links lib<name>.a
env_var: "ELEPHC_<NAME>_LIB_DIR", // dir override for prebuilt staticlibs
crate_name: "elephc-<name>", // cargo package (auto-build + workspace)
flag_name: "<name>", // user-facing `--with-<name>` flag
whole_archive: false, // true if link-time side effects / owns entry
macos_frameworks: &[], // transitive native deps' frameworks
needs_libdl: true, // Rust runtime/unwinder needs -ldl on Linux
},Set whole_archive: true only when the staticlib has link-time side effects that
must survive (e.g. a provider registration) or owns the program entry point (like
elephc_web). Otherwise leave it false; --with-<name> force-loads it anyway.
Pick one of two paths so PHP code can actually call into the crate. Both make the
type checker record elephc_<name> as a required library, which is what links the
bridge automatically when the feature is used.
-
Core builtins — when the feature is a set of PHP built-in functions (
md5(),hash(), …). Follow "Adding a built-in function" above (declare each builtin insrc/builtins/<area>/with its semantic descriptor), and declareBuiltinRequirement::Bridge("elephc_<name>")in its fixed requirements or return it from the descriptor's source-dependentrequirementsresolver. The PHP names are always available, so no prelude is needed. -
A prelude — when the feature is a set of classes/functions written in elephc-PHP that wrap the crate (PDO, timezone introspection, image). Add
src/<name>_prelude.rs:- a static elephc-PHP source string declaring
extern "elephc_<name>" { ... }plus the wrapper classes/functions; pub fn inject_if_used(program: Program, force: bool) -> Programthat returnsprogramunchanged when!force && !detect::program_uses_<name>(&program), and otherwise tokenizes/parses the prelude and prepends it (declarations are hoisted, so prepending does not change execution order);- a
detectsubmodule that scans the AST for the feature's symbols. Wire the call intosrc/pipeline.rsafter include resolution, mirroring the PDO block. The injectedextern "elephc_<name>"block is what adds the bridge torequired_libraries, so usage auto-links it.
- a static elephc-PHP source string declaring
Registering the bridge in BRIDGES with a flag_name is what enables the flag —
src/cli.rs parses --with-<flag_name> generically against the table (an unknown
crate is rejected, listing the valid ones), and src/pipeline.rs force-links the
bridge (whole-archived via forced_bridge_libs) for every crate named in
with_crates.
If your crate uses a prelude, also thread the force flag into its
inject_if_used call in src/pipeline.rs, mirroring pdo/tz/image:
let ast = <name>_prelude::inject_if_used(ast, with_crates.contains("<name>"));so --with-<name> declares the PHP surface even when auto-detection would not.
Core-builtin crates need nothing extra here — force-linking the staticlib is
enough because their PHP names are always available.
--with-<name> semantics: it guarantees the crate is compiled in (whole-archived,
not dead-stripped) and, for prelude crates, that its API is declared — useful when
detection cannot see indirect usage. It is additive and never disables
auto-detection. Note that it increases binary size by force-including the whole
crate.
The CLI also has an explicit runtime-capability exception, --with-regex.
Unlike a bridge flag, it enables RuntimeFeatures::regex for opaque dynamic
eval source and requires the project's managed pcre2 package; it does not add
an entry to BRIDGES. Keep any future non-bridge --with-<name> capability in
the explicit CLI capability list and document why normal source detection
cannot see it.
- Unit-test the
BRIDGES/flag mapping insrc/linker.rsand the CLI parsing insrc/cli.rs(both are fast, no assembler/linker). - Add codegen/end-to-end tests that exercise the feature, and error tests for
argument-count/usage diagnostics, per the test-coverage rules in
CLAUDE.md. - The crate itself should have
cargo test -p elephc-<name>unit tests (that is what therlibcrate-type is for). - Run focused tests locally; CI runs the full supported-target matrix.
docs/compiling/cli-reference.md— add the--with-<name>flag.docs/compiling/linking-and-conditional-compilation.md— describe the bridge and its auto-link trigger.- The relevant
docs/php/ordocs/beyond-php/page — document the PHP surface. - Update
CLAUDE.mdonly if you changed the bridge/flag mechanism itself.
By submitting a contribution to this repository, you represent and warrant that:
- you have the legal right to submit the contribution;
- the contribution is your original work, or you have sufficient rights to submit it;
- to the best of your knowledge, the contribution does not knowingly infringe the intellectual property rights of any third party;
- you agree that your contribution will be distributed under the same license as the Elephc project.
You retain the copyright to your contributions.
Please be respectful and constructive.
Healthy technical discussions are encouraged. Personal attacks, harassment, or disrespectful behavior will not be tolerated.
If you're unsure about anything, feel free to open an issue or start a discussion.
Contributions of all sizes are welcome.
Happy hacking! 🐘