feat!: PHP 8 union, intersection, DNF, and class-union type hints#734
Draft
ptondereau wants to merge 38 commits intomasterfrom
Draft
feat!: PHP 8 union, intersection, DNF, and class-union type hints#734ptondereau wants to merge 38 commits intomasterfrom
ptondereau wants to merge 38 commits intomasterfrom
Conversation
Add PhpType with Simple and primitive Union variants so extension authors can declare arguments like int|string whose union shape is visible to PHP reflection. Arg::new now takes impl Into<PhpType> so every existing Arg::new(name, DataType::X) call keeps compiling via a From<DataType> for PhpType impl. For pure primitive unions the outer zend_type.type_mask ORs the member MAY_BE_* bits directly, with no zend_type_list allocation, which matches what stub-generated PHP code does for int|string-style hints. The runtime fast path zend_check_type reads exactly that outer mask, so metadata is consistent with how the engine validates types in debug builds. List-backed unions (class unions, intersections, DNF) are deferred to a follow-up. PHP only enforces internal-function arg types in debug builds (see zend_internal_call_should_throw in Zend/zend_execute.c). The integration test therefore verifies the emitted metadata through ReflectionFunction rather than asserting TypeError at call time. The cleanup_module_allocations path now skips list-backed types because Zend frees the list itself via pefree at MSHUTDOWN (Zend/zend_opcode.c:112-124); this guards against double-free once later slices add list-backed unions. Refs #199
Verify and document that PhpType::Union accepts DataType::Null as a member to express int|string|null. The Slice 1 emitter already produces the right bits because primitive_may_be(DataType::Null) returns 1 << IS_NULL = 2, which is the same value as _ZEND_TYPE_NULLABLE_BIT (see Zend/zend_types.h:148). This slice adds: * doc on PhpType::Union spelling out the equivalence between Union(vec![T, ..., Null]) and Union(vec![T, ...]) + .allow_null(), citing the bit equality so future maintainers can verify * two new integration functions (test_union_int_string_or_null and test_union_int_string_allow_null) covering both spellings * PHP-side reflection assertions that allowsNull() is true and that the union exposes int, null, and string members in either spelling * call-site assertions for int, string, and null arguments No production code change in src/zend/_type.rs; the slice is a verification + documentation step on top of Slice 1. Refs #199
Extend FunctionBuilder::returns to accept impl Into<PhpType> so
extension authors can declare function int|string or
function int|string|null in return position. The existing single-type
callers (including every macro-generated function and method) keep
compiling thanks to From<DataType> for PhpType.
The build path now dispatches the retval ArgInfo on the PhpType variant:
PhpType::Simple keeps the existing ZendType::empty_from_type emission;
PhpType::Union routes through ZendType::empty_from_primitive_union
(already shipped in slice 1). The Void/Mixed nullable filter that
prevents ?void and ?mixed only applies to single types now; unions
cannot be Void or Mixed in PHP syntax, so the user's allow_null is
honoured directly for them.
The describe ABI Retval { ty: DataType, nullable: bool } stays
unchanged. Both From<FunctionBuilder> for Function and
From<(FunctionBuilder, MethodFlags)> for Method now go through a small
private helper retval_to_describe that lossy-maps PhpType::Union to
DataType::Mixed while preserving the nullable bit (computed as the OR
of allow_null and DataType::Null membership). Stubs continue to render
union returns as mixed until a follow-up widens the Retval ABI; the
runtime zend_type carries the truthful union shape so PHP reflection
sees the real members.
Tested via two new integration functions test_returns_int_or_string
and test_returns_int_string_or_null whose ReflectionFunction return
type assertions verify the union members and nullability.
Refs #199
The describe ABI now models `PhpType` faithfully. `Parameter.ty` is `Option<PhpTypeAbi>` and `Retval.ty` is `PhpTypeAbi`, where `PhpTypeAbi` mirrors `crate::types::PhpType` with ABI-stable wrappers. Conversion from `Arg` and `FunctionBuilder` no longer drops `PhpType::Union(_)` to `None` / `Mixed`, and the stub renderer emits `int|string` and `int|string|null` for union parameters and return types. Nullable handling keeps the existing rules: `?T` shorthand for single types, explicit `|null` for unions, and no duplicate `null` when already a member. Refactor: a shared `render_type_with_nullable` helper replaces three copies of the `?T` dispatch in `Function::fmt_stub`, `Method::fmt_stub`, and `param_to_stub`. BREAKING CHANGE: `describe::Parameter.ty` is now `Option<PhpTypeAbi>` and `describe::Retval.ty` is now `PhpTypeAbi`. External consumers that match on `DataType` directly must wrap their patterns in `PhpTypeAbi::Simple(...)` or handle the new `Union(...)` variant. The struct layout change requires the extension and `cargo-php` CLI to be built from the same `ext-php-rs` version.
`ClassProperty.ty` now accepts `Option<PhpType>` and the describe ABI's `Property.ty` mirrors the parameter / retval shape with `Option<PhpTypeAbi>`. The stub renderer for properties reuses the `render_type_with_nullable` helper, so `public int|string $foo` and `public int|string|null $foo` (via either spelling) render the same way they do for parameters. `phptype_to_phpdoc` now expands unions in PHPDoc `@var` tags as `int|string|null` instead of falling back to `mixed`. Macro-generated property metadata at `src/builders/module.rs` is converted via `desc.ty.into()`, so existing `#[php_class]` users see no source change. BREAKING CHANGE: `crate::builders::ClassProperty.ty` is now `Option<PhpType>` (was `Option<DataType>`) and `describe::Property.ty` is now `Option<PhpTypeAbi>` (was `Option<DataType>`). Manual property registrations must wrap their `DataType` in `PhpType::Simple(...)` or rely on `Some(DataType::X.into())`. Consumers of the describe module that match on `DataType` directly must wrap their patterns in `PhpTypeAbi::Simple(...)` or handle the new `Union(...)` variant.
Add PhpType::ClassUnion(Vec<String>) to express class unions like Foo|Bar on argument and return position. This is the first variant that allocates a zend_type_list at runtime: primitive unions only OR MAY_BE_* bits into the outer mask, but class members must live inside a list so each entry can carry a class-name pointer. Two cfg-split paths for full PHP 8.1-8.5 coverage: - 8.3+: emit a single CString of pipe-joined names tagged with _ZEND_TYPE_LITERAL_NAME_BIT. Zend's zend_convert_internal_arg_info_type splits on `|`, allocates the zend_type_list, and interns each member at registration (Zend/zend_API.c:2929-2972 in php-src). Mirrors slice 1's single-class literal-name strategy. - 8.1/8.2: manually allocate the zend_type_list via __zend_malloc (matches pemalloc(_, 1)), intern each member as a persistent zend_string via a new src/zend/string.rs wrapper, and OR _ZEND_TYPE_LIST_BIT | _ZEND_TYPE_UNION_BIT into the outer mask. Zend reclaims via zend_type_release -> pefree(_, 1) at MSHUTDOWN; the existing list-bit guard in cleanup_module_allocations already handles teardown. Wires the variant through Arg::as_arg_info, FunctionBuilder::returns/build retval emission, the legacy _zend_expected_type fallback (Z_EXPECTED_OBJECT with the standard +1 nullable bump), and a placeholder map in PhpTypeAbi::From that the next commit replaces with a proper describe-side variant. Mixed unions like int|Foo are not yet expressible; that's the future DNF representation in slice 04.
Extend the describe ABI with PhpTypeAbi::ClassUnion(Vec<RString>) and the matching renderer arms in fmt_stub, render_type_with_nullable, and phptype_to_phpdoc. Class names are emitted with the FQDN backslash prefix that the existing single-class stub path uses (\Foo|\Bar). Nullable rendering: class union members can never be DataType::Null, so the dedup check that primitive unions need (skip " |null" if Null is already a member) collapses to "always append |null when nullable" for class unions. PHPDoc rendering follows the same shape. Replaces the placeholder map (PhpType::ClassUnion -> Self::Simple(Mixed)) introduced in the previous commit with the proper variant mapping. Slice 4 already established the !-marker chain so release-plz computes a single cumulative major bump.
Register two minimal #[php_class] structs (ClassUnionLeft, ClassUnionRight) plus four FunctionBuilder functions: an arg-only Foo|Bar, an arg-only Foo|Bar with .allow_null(), and the matching return-type variants. The companion class_union.php drives Reflection assertions against ReflectionUnionType members and allowsNull(), mirroring the slice 1-3 metadata-first style (PHP only enforces internal-function arg types in debug builds, so call-time TypeErrors are not a stable surface). This locally exercises the PHP 8.3+ literal-name path; the 8.1/8.2 list allocation path is verified through the same test in CI's per-version matrix (.github/workflows/build.yml: 8.1, 8.2, 8.3, 8.4, 8.5 nts/ts). NTS and ZTS pass on PHP 8.4 here.
Replaces the hand-rolled zend_type_list allocation for PHP 8.1/8.2 with the same literal-name-plus-pipe-joined-CString shape used on 8.3+. Reading upstream zend_API.c shows that zend_register_functions itself splits on `|`, allocates the list, and interns each member at registration time on every supported version (8.1.0 :2815-2855, 8.2.0 :2860-2895, 8.3+ :2929-2972) — the only thing that changes between major versions is the bit's *name*: - 8.1/8.2: `_ZEND_TYPE_NAME_BIT` IS the literal-name bit (engine reads ptr as const char*). - 8.3+: `_ZEND_TYPE_LITERAL_NAME_BIT` was introduced when `_ZEND_TYPE_NAME_BIT` shifted to mean "pre-interned zend_string*". The previous list-allocation path also crashed in the integration test harness because it called zend_string_init_interned at get_module() time — before the engine has wired up that function pointer. The literal-name path defers all engine-touching work until zend_register_functions runs, sidestepping the lifecycle issue and dropping ~50 LOC of unsafe FFI plus the now-unused src/zend/string.rs wrapper. Also extracts arg_info_flags_with_nullable to centralise the nullable-bit threading shared by single-class, primitive-union, and class-union paths. Verified locally on PHP 8.2/8.3/8.4 NTS and 8.4 ZTS via temporary devshells (8.1 is EOL in nixpkgs unstable; CI matrix covers it).
Add `PhpType::Intersection(Vec<String>)` to express PHP 8.1+ class intersections like `Countable&Traversable`. Wires the variant through `Arg::as_arg_info`, `FunctionBuilder::returns`/`build` retval emission, and the legacy `_zend_expected_type` fallback (Z_EXPECTED_OBJECT). Unlike class unions, the literal-name shortcut does NOT work for intersections. Verified across PHP 8.1.34, 8.2.30, 8.3.30, 8.4.20, 8.5.5, and master: `zend_convert_internal_arg_info_type` only ever calls `strchr(p, '|')`. There is no `&` parsing path, so the engine will never rewrite an `&`-joined literal name into an `_ZEND_TYPE_INTERSECTION_BIT` list at registration time. `ZendType::empty_from_class_intersection` (cfg(php81)-gated) hand-rolls the canonical layout that `gen_stub.php` emits for property/argument intersection types (Zend/ext/zend_test/test_arginfo.h:1363-1370 in php-src): 1. Allocate a `zend_type_list` via `pemalloc(_, 1)`. New C shim `ext_php_rs_pemalloc_persistent` hides the file/line parameters that vary between debug and release builds. 2. For each class name, allocate a persistent zend_string tagged with `IS_STR_INTERNED`. New C shim `ext_php_rs_zend_string_init_persistent_interned` wraps the static-inline `zend_string_init` (no function pointer, safe at `get_module()` time) and sets the interned flag manually so Zend's `zend_string_release` becomes a no-op. The strings then survive embed-test MSHUTDOWN cycles, which would otherwise free them out from under our cached function entries. 3. Populate each list entry with `_ZEND_TYPE_NAME_BIT` and the just-allocated zend_string pointer. 4. Set `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_INTERSECTION_BIT | _ZEND_TYPE_ARENA_BIT` on the outer mask. The arena bit tells `zend_type_release` (Zend/zend_opcode.c:112-124) to skip the `pefree` of the list itself. Net effect: the list and its strings are persistently allocated once during `get_module()` and live for the process lifetime. The leak is bounded (one list + N strings per intersection arg/retval per module) and matches what PHP itself does for internal extension intersections. Nullable intersections (`?Foo&Bar`) are deliberately rejected at the FFI emission layer. PHP user code cannot spell `?Foo&Bar`; the legal form is the DNF `(Foo&Bar)|null` which is the responsibility of the future DNF representation. `Arg::new(.., Intersection(..)).allow_null()` returns `Err(InvalidCString)` so callers fail early instead of silently producing a half-built type. Adds `_ZEND_TYPE_INTERSECTION_BIT` and `_ZEND_TYPE_ARENA_BIT` to allowed_bindings.rs and regenerates docsrs_bindings.rs. Refs: #199
Add `PhpTypeAbi::Intersection(Vec<RString>)` to the ABI-stable describe enum and route `PhpType::Intersection(Vec<String>)` through the `From<PhpType>` arm. Stubs render `\Foo&\Bar` (FQDN-prefixed, ampersand-joined) via a new `fmt_stub` arm, and `phptype_to_phpdoc` gains a parallel arm for PHPDoc rendering. `render_type_with_nullable` ignores the nullable flag for `PhpTypeAbi::Intersection`. PHP cannot spell `?Foo&Bar`; the legal nullable form is the DNF `(Foo&Bar)|null`, which is the future DNF representation's responsibility. Slice 03's FFI path also rejects nullable intersections at construction time, so the stub side stays consistent with what is actually emittable. Breaking change marker: the describe ABI gains a new variant. Any out-of-tree consumer matching exhaustively on `PhpTypeAbi` needs an extra arm. release-plz picks up the major-version bump from the `!`. Refs: #199
Mirror the class_union integration test for class intersections. Registers `test_intersection_arg` and `test_intersection_returns` with `PhpType::Intersection(vec!["Countable", "Traversable"])`, then asserts via PHP `Reflection` that: - `getType()` is a `ReflectionIntersectionType`, - members are exactly `Countable&Traversable`, - `allowsNull()` is false (nullable intersections are deferred to DNF). Wired in `tests/src/lib.rs` and `tests/src/integration/mod.rs` behind `#[cfg(php81)]`. Built-in PHP interfaces (`Countable`, `Traversable`) are used so no Rust-side class registration is needed. The pattern matches the metadata-first approach the class_union tests already established: PHP only enforces internal-function arg types in debug builds (zend_internal_call_should_throw is `#if ZEND_DEBUG`), so runtime call-site enforcement is not a stable test surface. Drive-by: `tests/src/integration/union/mod.rs` picked up cargo fmt collateral that was sitting in the working tree. Refs: #199
Adds `nix develop .#php82` and `nix develop .#php83` shells alongside the existing `default` (current NTS) and `zts` shells. Lets us verify behaviour on every supported PHP version locally. Each shell mirrors the default's setup (libclang env, rust-overlay stable toolchain, embed support enabled) and pins the relevant `pkgs.php8X` attr from nixpkgs unstable. Refs: #199
Add `PhpType::Dnf(Vec<DnfTerm>)` (with `DnfTerm { Single(String),
Intersection(Vec<String>) }`) to express PHP 8.2+ Disjunctive Normal
Form types like `(A&B)|C` or `(Countable&Traversable)|null`. Wires the
variant through `Arg::as_arg_info`, `FunctionBuilder::returns`/`build`
retval emission, and the legacy `_zend_expected_type` fallback
(Z_EXPECTED_OBJECT, same as ClassUnion/Intersection).
`ZendType::empty_from_dnf` hand-rolls a nested `zend_type_list`:
* Outer list with `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_UNION_BIT |
_ZEND_TYPE_ARENA_BIT` plus optional `_ZEND_TYPE_NULLABLE_BIT`.
* Each `DnfTerm::Single(name)` becomes a list entry with
`_ZEND_TYPE_NAME_BIT` and a persistent-interned `zend_string*`.
* Each `DnfTerm::Intersection(names)` becomes a nested list
(`_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_INTERSECTION_BIT |
_ZEND_TYPE_ARENA_BIT`) populated identically to a flat intersection.
The arena bit on every level keeps `zend_type_release` from `pefree`-ing
our hand-allocations across embed MSHUTDOWN cycles.
Inner-list construction is shared with `empty_from_class_intersection`
via a new `build_class_list` helper, so flat and DNF intersections
allocate identically.
Validation (returns `None` -> `Err(InvalidCString)` upstream):
* `terms.len() < 2` is degenerate (use Simple, ClassUnion, or
Intersection instead).
* `DnfTerm::Intersection` carrying fewer than 2 members is rejected.
* Empty/NUL-bearing class names are rejected.
Nullability is canonicalised on `Arg::allow_null` / `ret_as_null`, never
as a `DnfTerm::Single("null")` term -- one canonical Rust spelling per
legal PHP type.
Version gate: `#[cfg(php83)]`. While DNF is a PHP 8.2 language feature
for user code (see https://php.watch/versions/8.2/dnf-types), php-src's
`zend_register_functions` only began honouring pre-built `zend_type_list`
for internal-function arg_info on PHP 8.3 (`Zend/zend_API.c` widened the
gate from `ZEND_TYPE_IS_COMPLEX` + assert `HAS_NAME` to
`ZEND_TYPE_HAS_LITERAL_NAME`). On 8.1/8.2 the engine asserts and/or
crashes (`strlen` past the `zend_type_list` struct). php-src itself
mirrors this: `gen_stub.php` only emits `ZEND_TYPE_INIT_INTERSECTION` /
DNF in arg_info on 8.3+. ext-php-rs returns `Err(InvalidCString)` on
older versions for the same reason -- same effective behaviour as
php-src.
This commit also corrects slice 03's intersection gate from `cfg(php81)`
to `cfg(php83)` for the same root cause: slice 03 was over-promised
because it was only verified on 8.4. Reproduced the SIGSEGV on 8.2.30
locally; rerouting the gate matches php-src behaviour. The flat
intersection unit tests now sit under `cfg(all(test, php83))`.
Refs: #199
Add `PhpTypeAbi::Dnf(Vec<DnfTermAbi>)` (with `DnfTermAbi` mirroring `DnfTerm` using `RString`) to the ABI-stable describe enum and route `PhpType::Dnf(Vec<DnfTerm>)` through the `From<PhpType>` arm. Stubs render `(\A&\B)|\C` (intersection groups parenthesised, single-class terms not, all FQDN-prefixed, pipe-joined) via a new `fmt_stub` arm, and `phptype_to_phpdoc` gains a parallel arm so PHPDoc output matches. `render_type_with_nullable` shares the `ClassUnion` arm: appends `|null` (never the `?` shorthand, since DNF is always a union). `retval_to_describe` extends its `ClassUnion | Intersection` nullability arm to include `Dnf`, so `ret_allow_null` flows through cleanly. The class-name-with-leading-backslash logic (used in the `ClassUnion`, `Intersection`, and now `Dnf` arms) is extracted into a `write_class_name` helper to avoid the third copy. ABI break: this is a `feat(describe)!` because adding a variant to `PhpTypeAbi` (`#[repr(C, u8)]`) shifts every variant's discriminant byte. The CLI must rebuild against the new ABI; release-plz computes the major bump from this marker. Refs: #199
Adds an integration test extension under `tests/src/integration/dnf/` that registers four functions exercising every DNF shape ext-php-rs needs to emit: * `test_dnf_arg`: `(DnfA&DnfB)|DnfC` argument * `test_dnf_nullable_arg`: `(DnfA&DnfB)|DnfC|null` argument via `.allow_null()` * `test_dnf_two_intersections_arg`: `(DnfA&DnfB)|(DnfA&DnfD)` argument * `test_dnf_returns`: `(DnfA&DnfB)|DnfC` return type * `test_dnf_nullable_returns`: `(DnfA&DnfB)|DnfC|null` return via the `allow_null` flag on `FunctionBuilder::returns` PHP-side `dnf.php` declares two interfaces and a class implementing both, then asserts `ReflectionUnionType::getTypes()` includes a `ReflectionIntersectionType` for the `&` group and a `ReflectionNamedType` for the single class. Mirrors the slice 03 intersection harness style (Reflection-driven assertions plus a smoke call). Gated `#[cfg(php83)]` on the test extension module declaration AND on the slice 03 intersection module: php-src's `zend_register_functions` only accepts pre-built `zend_type_list` for internal-function arg_info on 8.3+. Verified locally on 8.2 (graceful Err, intersection + DNF modules gated out), 8.3 NTS, 8.4 NTS, 8.4 ZTS. Refs: #199
Implement `impl FromStr for PhpType` returning
`Result<PhpType, PhpTypeParseError>`, plus `Display` for `PhpType` and
`DnfTerm` so any parser output round-trips through `format!("{}", ty)`.
The parser lives in the runtime crate per OWD-3 (one-way-door 3 of the
type-hints PRD): proc-macro callers (issue 06) and hand-written code now
share the same parsing logic. Recursive-descent over a manual top-level-pipe
/ top-level-amp split, mirroring `gen_stub.php`'s `Type::fromString`
char-by-char approach in php-src `build/gen_stub.php:540-576`.
Recognised primitives (case-insensitive): int, string, bool, true, false,
float, array, null, object, void, mixed, iterable, callable, resource.
`static`/`never`/`self`/`parent` are rejected with `UnsupportedKeyword`.
`boolean`/`integer`/`double`/`binary` are treated as class names: php-src
8.5 only deprecates these for casts (`Zend/zend_language_scanner.l:1641-1707`),
not for type hints.
Class-side nullable shapes (`?Foo`, `Foo|null`, `Foo|Bar|null`, `?A&B`,
`(A&B)|null`) are rejected with `ClassNullableNotRepresentable`. The runtime
`PhpType` cannot represent them as a single value:
- `DataType::Object` carries `Option<&'static str>`, so a runtime parser
cannot produce `Simple(Object(Some(_)))` without leaking per parse.
- `ClassUnion` and `DnfTerm::Single` deliberately don't admit a stringly
-typed `"null"` member (slice 04 design choice).
- `empty_from_class_intersection` rejects `allow_null=true`
(`src/zend/_type.rs:269`).
- `empty_from_dnf` rejects `terms.len() < 2` (`src/zend/_type.rs:359`).
Callers wanting these shapes parse the non-null form and chain
`Arg::allow_null()` on the resulting `Arg`. Primitive nullables (`int|null`,
`?int`) work because `DataType::Null` is a member, so `Union` carries it
inline. `parse("Foo")` canonicalises to `ClassUnion(vec!["Foo"])` (degenerate
but accepted; emitted bits are identical to a single-class arg_info).
`PhpTypeParseError` has 16 variants with hand-rolled `Display +
std::error::Error` to match the existing `crate::error::Error` style; the
workspace doesn't pull in `thiserror`.
27 new unit tests in `src/types/php_type.rs::tests`: 14 happy paths, 22
error paths, 7 Display + roundtrip. Full lib + clippy `--pedantic -D
warnings` + fmt clean on NTS and ZTS.
Wires the slice-05 type-string parser into #[php_function],
#[php_impl] methods, and #[php_interface] trait methods so authors
can declare compound PHP types directly on Rust signatures, e.g.
`#[php(types = "int |string")]` on a parameter or
`#[php(returns = "(\\A & \\B) |\\C")]` on a function. The override is
the source of truth for the registered PHP type, including
nullability; runtime modifiers (default, as_ref, variadic) still
apply.
The macro emits `PhpType::from_str(LIT)` at extension load rather
than at expansion time because crates/macros cannot depend on the
runtime crate (cargo dep cycle: ext-php-rs -> ext-php-rs-derive).
This matches issue 06 acceptance criterion 4 ("re-emitting the
source string and parse()-ing at registration"). Compile-time
validation is restricted to syntactic checks (allowed character
set, non-empty, length cap) with a span on the LitStr. Parser-
rejected strings panic at first `cargo run` with the original
literal in the message. A follow-up captures extracting the parser
to a shared crate so all errors surface at build time.
Per-arg `#[php(...)]` attributes are stripped from the re-emitted
ItemFn so rustc never sees the unknown attribute (regression guard
for PR #637's bug). Function-level `#[php(...)]` was already
stripped; this extends the strip to FnArg::Typed.attrs.
Verification:
- 36 macro-crate unit tests (10 new): syntactic validation, runtime
emit shape, parser strip, span-on-bad-input.
- 29 integration tests including new tests/src/integration/
php_types_attr/ module covering primitive union, class union,
intersection (cfg(php83)), DNF (cfg(php83)), function returns,
and #[php_impl] method coverage on both arg and return.
- Pass on PHP 8.2 NTS (intersection/DNF cfg-skipped), 8.4 NTS,
8.4 ZTS via Reflection assertions.
- cargo fmt --check + cargo clippy --workspace --all-targets
-- -D warnings clean.
Closes issue #199 acceptance criteria for slice 06.
Closes issue #199 slice 7. Authors can now model a PHP union as a Rust enum and have the macro infer the registered shape from the variants: #[derive(PhpUnion)] pub enum IntOrString { Int(i64), Str(String), } #[php_function] pub fn echo_either(value: IntOrString) -> IntOrString { value } PHP `ReflectionFunction` reports `int|string` on both parameter and return. The derive emits a `PhpUnion` impl with `union_types() -> PhpType` plus variant-dispatching `IntoZval`/`FromZval` impls. v1 supports newtype variants only; unit, struct, multi-field tuple, and generic enums are rejected at the variant span with remediation hints. To plumb the compound type through to function registration, the `IntoZval`, `FromZval`, and `FromZvalMut` traits gain a default-impl `fn php_type() -> PhpType { PhpType::Simple(Self::TYPE) }`. The function macro now emits `<T as FromZvalMut>::php_type()` for parameters and `<T as IntoZval>::php_type()` for returns; the derive overrides the method to delegate to `union_types()`. Backwards compatible: every existing impl picks up the default. The `Option<T>`, `Result<T, E>`, and blanket `FromZvalMut for T: FromZval` impls forward `php_type()` to the inner type. Verified on PHP 8.4 NTS + ZTS via integration tests covering Reflection metadata for `#[php_function]` and `#[php_impl]` methods plus end-to-end call round-trips for both variants.
The legacy `From<Arg<'_>> for _zend_expected_type` impl leaked the raw bindgen integer through the public API and silently fell back to a "first member" or `Z_EXPECTED_OBJECT` sentinel for compound types, which PHP's enum has no slot for. Drop it and replace with two inherent methods on `Arg`: * `expected_type(&self) -> Result<ExpectedType, Error>` returns the wrapped discriminant for scalar `DataType`, or `Err(NoExpectedTypeDiscriminant)` for `Union`/`ClassUnion`/ `Intersection`/`Dnf` and scalar variants without a slot (`Mixed`, `Void`, `Iterable`, `Callable`, `Null`). * `ty(&self) -> &PhpType` exposes the declared type. Callers use slice 5's `Display` impl to render the canonical PHP-syntax string (`int|string`, `\Foo|\Bar`, `\Countable&\Traversable`, `(\A&\B)|\C`) and feed it to `zend_argument_type_error` or `PhpException`, which is what php-src itself does in `Zend/zend_API.c` and `ext/standard/array.c` for compound types. The new safe wrapper module `src/zend/expected_type.rs` houses the `#[non_exhaustive] pub enum ExpectedType` (14 variants covering the 7 supported scalars times nullability) and the `wrong_parameter_type_error` wrapper around `zend_wrong_parameter_type_error`. After this commit the raw `_zend_expected_type` integer and its constants are referenced only inside that one file; `src/args.rs` no longer imports any `_zend_expected_type_*` symbol. The earlier `+1`-for-nullable arithmetic (which assumed PHP's enum is laid out as alternating BASE/BASE_OR_NULL pairs) is replaced with explicit per-variant match arms keyed to the bindgen constants, eliminating the layout dependency. Adds `Error::NoExpectedTypeDiscriminant`. Adds the 7 missing `Z_EXPECTED_*_OR_NULL` constants and `zend_wrong_parameter_type_error` to `allowed_bindings.rs`. Regenerates `docsrs_bindings.rs` accordingly. The `wrong_parameter_type_error` wrapper has a compile-time signature assertion to catch FFI drift; behavioural verification cannot run from a bare `Embed::run` because PHP's helper calls `get_active_function_or_method_name()` which asserts `zend_is_executing()`. Verified on PHP 8.4 NTS (414 lib tests) and ZTS (411 lib tests), 36 integration tests, `cargo clippy --features embed --all-targets -- -D warnings` clean, `cargo fmt --check` clean. BREAKING CHANGE: removes the public `impl From<Arg<'_>> for _zend_expected_type` and its raw constant imports from `crate::args`. Downstream extensions that consumed the impl should switch to `Arg::expected_type()` and `crate::zend::wrong_parameter_type_error()`. Closes issue 08.
Pre-existing `clippy::doc_markdown` (pedantic) warning from slice 6.
Three `clippy::unused_self` warnings on `PhpTypesAttrHolder::accept`, `PhpTypesAttrHolder::produce`, and `PhpUnionHolder::accept`: the fixtures declared `&self` but never read instance state, which was a genuine fixture smell. Drop `&self` to make them static methods. The macro's per-arg `#[php(types = ...)]` extraction already returns `None` for receivers (`extract_arg_php_type_overrides`), so the attribute-handling coverage is identical for static and instance methods. The instance-method dispatch path is still exercised by the `tests/src/integration/class/` fixtures with their real `self.field` accesses. `php_union.php` updated from `$holder->accept(...)` to `PhpUnionHolder::accept(...)` to match the now-static method. One `clippy::uninlined_format_args` in `crates/macros/src/function.rs` test: inline the `err` capture in the assertion message. After this commit `cargo clippy --features embed --all-targets --workspace -- -D warnings -W clippy::pedantic` is clean on PHP 8.4 NTS and ZTS.
…perty Switch ClassBuilder::register from the untyped zend_declare_property to zend_declare_typed_property whenever ClassProperty.ty.is_some(). Properties without a type continue through the untyped path. Promotes property types from stub-only documentation to runtime-enforced types on every supported PHP version. Why class types need a separate code path from the existing arg_info constructors: zend_declare_typed_property stores the zend_type verbatim with no literal-name preprocessing. Class names must reach the engine as zend_string* (not const char* literals); class unions must be pre-built zend_type_lists. The literal-name shape used for arg_info would crash on first runtime access. New ZendType::empty_for_property dispatcher with four private constructors emits the right shape for each PhpType variant. Property intersection lifts to cfg(php81) and property DNF to cfg(php82) since zend_declare_typed_property accepts pre-built lists on every version that supports the language feature. New Zval::undef() provides IS_UNDEF defaults for typed properties without an explicit default, matching what php-src gen_stub.php emits. IS_NULL would either fail declaration on non-nullable types or violate the type model. Property allocations are engine-managed: refcounted persistent strings (no IS_STR_INTERNED), no _ZEND_TYPE_ARENA_BIT on the list. The engine's zend_type_release reclaims everything at internal-class destroy. The property name string is released right after the call because zend_declare_typed_property copies + interns it for persistent classes. build_class_list is parameterized with an interned bool flag so the arg_info path keeps its leak-forever lifecycle and the property path gets engine-managed cleanup. Coverage: tests/src/integration/typed_property/ exercises every variant with Reflection metadata assertions, runtime TypeError on bad assignments, name round-trip via ReflectionProperty::getName(), and IS_PROP_UNINIT semantics via Error catch. Nine new unit tests in _type.rs::property_tests verify bit-level shapes including absence of _ZEND_TYPE_ARENA_BIT on the property path. BREAKING CHANGE: Properties declared via ClassBuilder with ty=Some(_) are now type-enforced at runtime. Any caller that previously assigned type-violating values to typed properties from PHP will now throw TypeError where the assignment used to silently succeed.
Cover the validation gates of register_property that fire before any FFI dispatch, so they can be exercised against a zeroed ClassEntry (the same pattern ClassBuilder::new uses for its internal entry). Each test picks an input that fails at a distinct gate, so a refactor that moves a gate to the wrong side of the FFI call would surface as either a missing Err here or a crash on the zeroed entry. Tests added: - empty class union via PhpType::ClassUnion(vec![]) - interior NUL in single-class name - empty single-class name - interior NUL in a class union member - interior NUL in untyped property name - intersection on PHP < 8.1 (cfg-gated) Happy-path coverage runs through tests/src/integration/typed_property/ since exercising the FFI call requires a fully-registered class entry inside an Embed run. Memory: production path verified leak-clean by running the integration typed_property fixture under valgrind with USE_ZEND_ALLOC=0 and --error-exitcode=42; valgrind exits 0, signalling no definite or indirect leaks. PHP CLI's fast-exit path truncates valgrind's leak summary text but the exit code is authoritative.
Slice 10 of issue #199. Splits the PHP type-hint parser out of the runtime crate so the proc-macro crate can call it at expansion time and parse-error reporting moves from extension load to `cargo build`. New workspace member `crates/types` (package `ext-php-rs-types`) hosts `PhpType`, `DnfTerm`, `PhpTypeParseError`, the recursive-descent parser, `DataType`, and their `Display` and `Default` impls. The runtime crate re-exports the moved items at their previous public paths (`ext_php_rs::types::{PhpType, DnfTerm, PhpTypeParseError}` and `ext_php_rs::flags::DataType`), so user code keeps compiling. Behind a `proc-macro` cargo feature the new crate also exposes `quote::ToTokens` impls that emit literal `PhpType::*` token trees referencing the runtime crate's public paths; default features keep `quote`/`proc-macro2` out of the runtime dep tree. The macro `emit_phptype_from_str(lit: &LitStr) -> TokenStream`, which emitted a runtime `from_str(LIT).unwrap_or_else(panic!)` call, is gone. The replacement `parse_php_type_litstr(&LitStr) -> Result<PhpType>` parses at expansion time and maps `PhpTypeParseError` to a `syn::Error` spanned on the literal. The lightweight syntactic guard `validate_php_types_litstr` plus its `PHP_TYPES_ALLOWED` and `PHP_TYPES_MAX_LEN` constants are removed: the parser is the single source of truth. `Function.returns_override` and `TypedArg.php_type_override` storage flips from `Option<LitStr>` to `Option<PhpType>`; emission becomes a plain `quote!(#parsed)` at the two call sites in `function.rs` plus the matching paths in `impl_.rs` and `interface.rs`. BREAKING CHANGE: `DataType::as_u32`, `impl From<u32> for DataType`, and `impl TryFrom<ZvalTypeFlags> for DataType` are removed. The orphan rule forbids those impls from staying in the runtime crate now that `DataType` lives in `ext-php-rs-types`. They are replaced by three free functions in `ext_php_rs::flags`: `data_type_as_u32`, `data_type_from_raw`, and `data_type_try_from_zvf`. Five internal call sites are migrated; `dd-trace-php` does not use these conversions. Workspace dependencies for `quote` and `proc-macro2` are hoisted to `[workspace.dependencies]` since two crates now share them. Closes the slice-06 acceptance gap: `#[php(types = "?Foo")]` and similar parser-rejected strings now fail at `cargo build` with the diagnostic spanned on the literal, not at first `cargo run`. The "load-time panic" footnote in `guide/src/macros/function.md` is removed and `guide/src/types/index.md` is updated to mention compile-time parsing.
…world Adds a `flexible_id` function to the hello_world example that uses `#[php(types = "int |string")]` on the argument and `#[php(returns = "int|string|null")]` on the return — both strings are parsed at macro-expansion time after slice 10, so the example also serves as a smoke test for the compile-time path.
Closes the parameter/return parity gap inherited from slice 06. The parameter side of the `php_types_attr` suite already exercises class union, intersection, and DNF compound types end-to-end, but only the primitive `int|string|null` return was covered. Class union, intersection, and DNF returns now have matching `ReflectionFunction::getReturnType()` assertions that mirror the existing parameter-side reflection blocks. Function-level: `test_attr_returns_class_union` on all PHP versions, `test_attr_returns_intersection` and `test_attr_returns_dnf` gated on `cfg(php83)`. Method-level: `produce_class_union` joins the existing `PhpTypesAttrHolder`; `produce_intersection` and `produce_dnf` ship on a new `#[cfg(php83)] PhpTypesAttrHolder83`. The split is forced by `#[php_impl]`: it re-emits the original `ItemImpl` (preserving cfg attrs on individual methods) but separately emits wrapper-handler tokens via `Function::function_builder()` that statically reference `Self::method_name()` without propagating the cfg. A cfg-gated method inside `#[php_impl]` would therefore fail to compile on PHP 8.1/8.2, and an always-emit alternative is destructive because `ClassBuilder::register()` panics MINIT on the first method-build `Err`. The runtime path was shipped in slice 04 (`FunctionBuilder::returns`); this commit adds no capability, only coverage. Closes issue 11. Refs: #199
Extends `examples/hello_world.rs` with three new functions and a second `#[php_class]` (`OtherTestClass`) so readers see Rust-defined struct names referenced by literal inside `#[php(types = "...")]` and `#[php(returns = "...")]` strings. `accept_class_value` accepts a `\TestClass|\OtherTestClass` parameter via `&Zval`, mirroring `flexible_id`'s primitive-side shape on the class side. `produce_test_class_or_other` returns a concrete `TestClass` while `#[php(returns = "\TestClass |\OtherTestClass")]` widens the registered metadata, demonstrating that the override genuinely widens the inferred return type rather than just duplicating it. The doc comment on each function explains the compile-time parse behavior so readers learn why the literal can reference Rust struct names directly. A bonus `IntOrFloat` enum + `pick_number` function showcases `#[derive(PhpUnion)]` for primitive-typed variants. The enum's doc comment explicitly flags that class-typed variants are not yet supported by the derive (the macro at `crates/macros/src/php_union.rs:75-77` collapses class names to `MAY_BE_OBJECT`, and `#[php_class]` only emits `FromZval<'a> for &'a T` rather than the owned form the derive needs). Readers needing class-union enums today are pointed at `produce_test_class_or_other` for the working override path. Issue 12's suggested `#[php(returns = "\TestClass|null")] -> Option<TestClass>` shape was infeasible because slice 5's parser rejects all class-side nullable shapes (`ClassNullableNotRepresentable`); the alternative shape above covers the same educational delta without the nullable. The `cargo-php` `hello_world_stubs` snapshot was regenerated to reflect the new functions, the new class, and the updated function ordering. Verified locally on PHP 8.4 NTS + ZTS via `cargo build --example hello_world` and `cargo clippy --example hello_world -- -D warnings` clean; `cargo test -p cargo-php --test stubs_snapshot` green. Macro extension to support class-typed variants in `#[derive(PhpUnion)]` is tracked separately as a follow-up issue (slice 7 originally deferred this). Refs: #199
Extends the "Overriding the registered PHP type" section of `guide/src/macros/function.md` with a class-union code block and a paragraph on the leading-`\` namespace convention, closing the parameter/return parity gap that left readers without an explicit example of referencing `#[php_class]`-defined Rust structs by name inside the override attribute. The new code block sits right after the existing primitive `int|string` example and demonstrates the `accept`/`produce` pair on `\Foo|\Bar`. The follow-up paragraph explains: leading `\` matches PHP's global-namespace spelling; bare names work too because every `#[php_class]`-defined struct lands in PHP's global namespace, so `\Foo` and `Foo` produce the same registered metadata. The pre-existing compile-time parser-rejection note about `?Foo&Bar` was promoted into a bulleted list covering both that case and the new class-side nullable family (`?Foo`, `\Foo|null`, `\Foo|\Bar|null`, `(\A&\B)|null`). A workaround paragraph points readers at slice-5's deferred `parse_with_nullable` follow-up for the parser side and at restructuring the function signature for the application side. Issue 13's suggested `#[php(returns = "\Foo |null")]` shape was infeasible because slice 5's parser rejects all class-side nullables (`ClassNullableNotRepresentable`); the guide teaches it as a documented limitation rather than a working shape. `guide/src/macros/impl.md` was audited and left alone: it carries no override-attribute example block of its own (the shared `#[php_impl]` example already lives in `function.md`), so there is nothing to mirror. The new code block uses `rust,ignore` to match the existing override-section convention. Verified locally via `mdbook build guide/` clean (matches `.github/workflows/docs.yml:43-44`) and `cargo test --doc` green (78 passed; the new `rust,ignore` blocks do not compile, so the doctest count is unchanged). Closes issue 13. Refs: #199
Run tools/update_lib_docs.sh so the embedded `///` blocks in crates/macros/src/lib.rs match guide/src/macros/function.md (new "Overriding the registered PHP type" section) and guide/src/macros/ zval_convert.md (PhpUnion derive description), keeping rust-analyzer hover docs aligned with the published guide.
The phrase "slice-06 attribute" referenced internal planning vocabulary from a private scratch document and added no value to public readers. The attribute is named `#[php(types = ...)]`; that's all the cross-reference the page needs.
Coverage Report for CI Build 25385695435Coverage increased (+6.2%) to 72.479%Details
Uncovered Changes
Coverage Regressions3 previously-covered lines in 2 files lost coverage.
Coverage Stats
💛 - Coveralls |
|
| Branch | feat/issue-199-primitive-union-types |
| Testbed | PHP 8.4.20 (cli) (built: Apr 28 2026 22:03:32) (NTS) |
⚠️ WARNING: Truncated view!The full continuous benchmarking report exceeds the maximum length allowed on this platform.
🐰 View full continuous benchmarking report in Bencher
⚠️ WARNING: No Threshold found!Without a Threshold, no Alerts will ever be generated.
a1da07e to
fed87a4
Compare
The CI typos check flagged `lits` as a typo of `list` six times in `DnfTerm` / `PhpType` ToTokens impls. The variable holds a `String::as_str` iterator splatted into `quote!` for class-name intersection / class-union / DNF token trees, so `literals` reads better and clears the lint.
Clippy doc-markdown flagged two bare `arg_info` mentions in the "Overriding the registered PHP type" block. Wrap them in backticks in guide/src/macros/function.md and resync the embedded copy in crates/macros/src/lib.rs via tools/update_lib_docs.sh.
The parent module's `use crate::types::DnfTerm` is gated on `#[cfg(php82)]` because all non-test `DnfTerm` users are 8.2+. The `empty_for_property_dnf_returns_none_pre_82` test is the inverse — it runs only when `#[cfg(not(php82))]` — so the parent import is invisible and the test failed to compile on PHP 8.1. Add a local `use` inside the test so the symbol is available in exactly the cfg where the test lives.
Refresh macro formatting, docsrs bindings, and the interface.expanded.rs fixture so the expanded outputs stay in sync with the macro pipeline after the php_type() switch and intermediate macro changes.
`zend_declare_typed_property` for persistent (internal) classes only accepts DNF types from PHP 8.3 (upstream commit 7f1c3bf09bb, "Adds support for DNF types in internal functions and properties"). PHP 8.2's implementation iterates the type list and asserts `ZEND_ASSERT(!ZEND_TYPE_HAS_LIST(*single_type))` per member. Any DnfTerm::Intersection sets `_ZEND_TYPE_LIST_BIT` on its member entry, tripping the assertion in debug builds. MINIT then dies and every integration test that loads the cdylib fails with the same stack (33 of 39 on the macOS 8.2 ts CI cell). Move `empty_from_dnf_for_property` from cfg(php82) to cfg(php83) so property registration returns None on 8.2, and align the typed_property integration test (Rust + PHP) to the same gate. The DNF language feature still works in userland on 8.2; we just cannot register such a property from the Zend API there. The arg_info side already had the right gate (cfg(php83) in args.rs).
The production cdylib must not link libphp: a second DT_NEEDED
mapping at extension load makes function pointers like
zend_string_init_interned read as NULL from that copy, panicking
MINIT on the first class registration. But the host test executable
on macOS-15 chained-fixup runners can no longer leave PHP runtime
data symbols (zend_ce_traversable, etc.) unresolved via
-Wl,-undefined,dynamic_lookup; chained fixups bind them at image
load and abort the binary with "symbol not found in flat namespace".
This change introduces:
- A force-link flag (EXT_PHP_RS_LINK_LIBPHP=1) that requests
-lphp at link time. Off by default for the production cdylib.
- A probe in unix_build.rs that scans <php-config --prefix>/lib
for libphp*.{so,dylib,tbd,a}. The `embed` feature stays strict
because the standalone binary genuinely needs libphp at link
time; probing there would mask a real configuration error.
- A bail when force-link is requested but no libphp is found,
with an actionable message (install hint, RUSTFLAGS escape
hatch, env-var unset). Replaces the prior silent skip.
- Workflow: skip the cargo test step on macOS for now since
shivammathur/setup-php's Homebrew formulas (NTS php@x.y and
-debug-zts) do not ship libphp at the standard path. Build
coverage stays. Restore once a libphp is wired into the runner.
- The integration test harness clears EXT_PHP_RS_LINK_LIBPHP on
the inner cargo build that produces libtests.so so the cdylib
under test does not pick up -lphp.
Also pulls in a batch of doc-link spelling fixes that landed
alongside the initial libphp work (`[crate::flags::DataType]`
instead of `[DataType]`, etc.) so rustdoc resolves the cross-module
references correctly.
020092c to
32bbe05
Compare
FunctionHandler resolves to extern "C" on unix and to
extern "vectorcall" on windows. The new compound-type integration
handlers (class union, intersection, DNF, primitive union) and the
src/builders/function.rs noop_handler test helper were declared
as plain extern "C", which only matches the unix alias and so
failed to type-check on windows with E0308.
Wrap each handler in zend_fastcall! { ... }, the same macro
closure.rs and builders/class.rs already use. The macro rewrites
the ABI to vectorcall on windows and stays C on unix.
Also gates the noop_handler helper behind cfg(php83) since the
tests that consume it are all PHP 8.3+ (class union, intersection,
DNF return types).
Drops a redundant `#![cfg_attr(windows, feature(abi_vectorcall))]`
attribute inside src/describe/mod.rs's tests module: inner
`feature` attributes only take effect at the crate root, so it was
a no-op that produced "the `#![feature]` attribute can only be
used at the crate root" on every windows compile. The crate root
already enables the feature in src/lib.rs.
32bbe05 to
5570a51
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #199.
PHP 8.0 added union types (
int|string), 8.1 added intersection types (Countable&Traversable), and 8.2 added DNF types ((A&B)|C). Until nowext-php-rscould only register a single PHP type per parameter, return, or class property. This PR adds full support for all four compound shapes plus class unions (Foo|Bar), end to end: the engine reports the right metadata via Reflection, runtime checks reject bad calls with a realTypeError, and the stubscargo-phpproduces render every variant correctly.What authors get
Two new ergonomic surfaces:
#[php(types = "...")]on parameters and#[php(returns = "...")]on#[php_function]/#[php_impl]methods accept PHP type-hint strings:int|string|null,\Foo|\Bar,\Countable&\Traversable,(\Countable&\Traversable)|\Foo. The string is parsed at macro-expansion time, so syntax mistakes surface asrustcerrors with proper spans, not load-time panics.#[derive(PhpUnion)]on a Rust enum where each variant newtype-wraps a primitive (Int(i64),Str(String)) generates theIntoZval/FromZvalglue plus the union metadata, so the enum can be used directly as a parameter or return type.Class properties go further:
ClassBuilder::registernow useszend_declare_typed_propertywhenever the property carries a type. Typed properties are no longer documentation-only, the engine enforces them at runtime on every supported PHP version, including intersection (8.1+) and DNF (8.2+).Critical decisions
Compound types on function args require PHP 8.3+ for intersection and DNF. PHP 8.1/8.2 added the language feature for user code, but their
zend_register_functionsrejects pre-builtzend_type_listfor internal-functionarg_infoand re-parses from a literal string instead. The 8.3 change widened the gate to literal-name lists. On 8.1/8.2 we returnErr(InvalidCString)fromArg::as_arg_infoandFunctionBuilder::build, mirroring what php-src's owngen_stub.phpdoes. Class unions (Foo|Bar) and primitive unions (int|string) work on every supported version. Property-side intersection lifts to 8.1 and property DNF lifts to 8.2 becausezend_declare_typed_propertyaccepts pre-built lists everywhere.Class-side nullables are rejected by the parser.
?Foo,\Foo|null,\Foo|\Bar|null, and(\A&\B)|nullall fail withClassNullableNotRepresentablebecause the runtime crate's class storage cannot admit anullmember without a one-way-door change toPhpTypeitself. Primitive nullables (int|null,?int) work fine. The guide documents the limitation and a follow-up will pick betweenparse_with_nullableand a flag onPhpType.The type-string parser lives in a new workspace crate,
ext-php-rs-types. It started inside the runtime crate, then was extracted so the proc-macro can pull it in directly and validate#[php(types = "...")]strings at compile time. The runtime crate re-exports the public types so user code keeps building unchanged.Breaking changes
The describe ABI widens to carry compound types end-to-end. Anyone consuming the describe layer directly (not via
cargo-php) needs to update:Parameter.ty,Retval.ty,Property.ty,ClassProperty.tyare nowPhpTypeAbi(an ABI-stable enum withSimple/Union/ClassUnion/Intersection/Dnfarms) instead ofDataType.From<Arg<'_>> for _zend_expected_typeis gone. The replacement isArg::expected_type(&self) -> Result<ExpectedType, Error>plus a safewrong_parameter_type_error(arg_num, expected, given)wrapper.ExpectedTypeis a#[non_exhaustive]enum covering the 14 valid (scalar, nullable) pairs PHP's discriminant supports; compound types returnErr(Error::NoExpectedTypeDiscriminant)since the discriminant doesn't represent them. Callers that need a string for compound types can nowformat!("{}", arg.ty())via the newDisplayimpl onPhpType.impl DataTypeinherentas_u32,From<u32> for DataType, andTryFrom<ZvalTypeFlags> for DataTypemove to free functions inext_php_rs::flags(data_type_as_u32,data_type_from_raw,data_type_try_from_zvf). Forced by the orphan rule onceDataTypemoved intoext-php-rs-types.ClassBuilder::registerswitching tozend_declare_typed_propertymeans properties without an explicit default now holdIS_UNDEF(engine flags themIS_PROP_UNINIT) instead ofIS_NULL. This matches what php-src's owngen_stub.phpproduces. Assignments to a typed property of an incompatible PHP value now throwTypeErrorinstead of silently coercing.Migration: if you stay on
cargo-phpand the high-level#[php_function]/#[php_impl]/ClassBuilderAPIs your existing single-type code keeps working unchanged. The breaks land on direct describe-ABI consumers and on code that constructed_zend_expected_typeby hand.Checklist