Skip to content

feat!: PHP 8 union, intersection, DNF, and class-union type hints#734

Draft
ptondereau wants to merge 38 commits intomasterfrom
feat/issue-199-primitive-union-types
Draft

feat!: PHP 8 union, intersection, DNF, and class-union type hints#734
ptondereau wants to merge 38 commits intomasterfrom
feat/issue-199-primitive-union-types

Conversation

@ptondereau
Copy link
Copy Markdown
Member

@ptondereau ptondereau commented May 4, 2026

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 now ext-php-rs could 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 real TypeError, and the stubs cargo-php produces 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 as rustc errors 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 the IntoZval / FromZval glue plus the union metadata, so the enum can be used directly as a parameter or return type.

Class properties go further: ClassBuilder::register now uses zend_declare_typed_property whenever 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_functions rejects pre-built zend_type_list for internal-function arg_info and re-parses from a literal string instead. The 8.3 change widened the gate to literal-name lists. On 8.1/8.2 we return Err(InvalidCString) from Arg::as_arg_info and FunctionBuilder::build, mirroring what php-src's own gen_stub.php does. 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 because zend_declare_typed_property accepts pre-built lists everywhere.

Class-side nullables are rejected by the parser. ?Foo, \Foo|null, \Foo|\Bar|null, and (\A&\B)|null all fail with ClassNullableNotRepresentable because the runtime crate's class storage cannot admit a null member without a one-way-door change to PhpType itself. Primitive nullables (int|null, ?int) work fine. The guide documents the limitation and a follow-up will pick between parse_with_nullable and a flag on PhpType.

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.ty are now PhpTypeAbi (an ABI-stable enum with Simple / Union / ClassUnion / Intersection / Dnf arms) instead of DataType.
  • From<Arg<'_>> for _zend_expected_type is gone. The replacement is Arg::expected_type(&self) -> Result<ExpectedType, Error> plus a safe wrong_parameter_type_error(arg_num, expected, given) wrapper. ExpectedType is a #[non_exhaustive] enum covering the 14 valid (scalar, nullable) pairs PHP's discriminant supports; compound types return Err(Error::NoExpectedTypeDiscriminant) since the discriminant doesn't represent them. Callers that need a string for compound types can now format!("{}", arg.ty()) via the new Display impl on PhpType.
  • impl DataType inherent as_u32, From<u32> for DataType, and TryFrom<ZvalTypeFlags> for DataType move to free functions in ext_php_rs::flags (data_type_as_u32, data_type_from_raw, data_type_try_from_zvf). Forced by the orphan rule once DataType moved into ext-php-rs-types.
  • ClassBuilder::register switching to zend_declare_typed_property means properties without an explicit default now hold IS_UNDEF (engine flags them IS_PROP_UNINIT) instead of IS_NULL. This matches what php-src's own gen_stub.php produces. Assignments to a typed property of an incompatible PHP value now throw TypeError instead of silently coercing.

Migration: if you stay on cargo-php and the high-level #[php_function] / #[php_impl] / ClassBuilder APIs your existing single-type code keeps working unchanged. The breaks land on direct describe-ABI consumers and on code that constructed _zend_expected_type by hand.

Checklist

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.
@coveralls
Copy link
Copy Markdown

coveralls commented May 4, 2026

Coverage Report for CI Build 25385695435

Coverage increased (+6.2%) to 72.479%

Details

  • Coverage increased (+6.2%) from the base build.
  • Patch coverage: 203 uncovered changes across 18 files (2871 of 3074 lines covered, 93.4%).
  • 3 coverage regressions across 2 files.

Uncovered Changes

Top 10 Files by Coverage Impact Changed Covered %
crates/types/src/php_type.rs 848 799 94.22%
src/builders/class.rs 92 63 68.48%
crates/types/src/data_type.rs 95 69 72.63%
src/flags.rs 60 34 56.67%
src/zend/_type.rs 566 548 96.82%
src/describe/stub.rs 426 417 97.89%
src/zend/expected_type.rs 170 161 94.71%
src/zend/module.rs 9 0 0.0%
crates/macros/src/php_union.rs 36 28 77.78%
src/describe/mod.rs 269 261 97.03%

Coverage Regressions

3 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
src/flags.rs 2 61.96%
src/zend/module.rs 1 8.33%

Coverage Stats

Coverage Status
Relevant Lines: 15886
Covered Lines: 11514
Line Coverage: 72.48%
Coverage Strength: 33.34 hits per line

💛 - Coveralls

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 4, 2026

🐰 Bencher Report

Branchfeat/issue-199-primitive-union-types
TestbedPHP 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.

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

🐰 View full continuous benchmarking report in Bencher

@ptondereau ptondereau force-pushed the feat/issue-199-primitive-union-types branch 3 times, most recently from a1da07e to fed87a4 Compare May 5, 2026 10:07
ptondereau added 6 commits May 5, 2026 17:09
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.
@ptondereau ptondereau force-pushed the feat/issue-199-primitive-union-types branch from 020092c to 32bbe05 Compare May 5, 2026 15:17
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.
@ptondereau ptondereau force-pushed the feat/issue-199-primitive-union-types branch from 32bbe05 to 5570a51 Compare May 5, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

no support for union, intersection & DNF type hints

2 participants