Skip to content

fix(preprocessor): reject variadic promoted properties and callable property/constant types - #64

Open
AlessioGiacobbe wants to merge 4 commits into
swoole:masterfrom
AlessioGiacobbe:split/promotion-callable-types
Open

fix(preprocessor): reject variadic promoted properties and callable property/constant types#64
AlessioGiacobbe wants to merge 4 commits into
swoole:masterfrom
AlessioGiacobbe:split/promotion-callable-types

Conversation

@AlessioGiacobbe

Copy link
Copy Markdown
Contributor

Two declaration gaps: variadic promoted constructor properties (__construct(public int ...$x)) compiled — the promotion was registered before the variadic check (Zend: "Cannot declare variadic promoted property") — and callable in property types (declared, promoted, union members) and class/interface constant types was accepted, where Zend rejects it ("Property A::$x cannot have type callable"; constants previously failed only incidentally with a confusing default-value error).

Messages include the full type string for composite types. Each rule probed against Zend 8.4.13.

Part of the split of #39.

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeDeclContainsCallable() deliberately returns false for every IntersectionType, but there is no merged fallback check on current master. A callable nested in DNF therefore reaches gen_stub.php and crashes on an assertion instead of producing a compiler diagnostic:

class Bag {
    public (Traversable&callable)|stdClass $value;
}

Current result: AssertionError: assert(!$type->isBuiltin) in StubType::getDnfTypeDeclarations(). Zend rejects this at the declaration (Type callable cannot be part of an intersection type). The same gap applies to typed class constants and interface properties/constants.

Please make this PR safe independently: recurse into intersection members and reject callable with a stable fatal diagnostic, or rebase after the generic compound-type validator is merged and add explicit DNF callable tests proving the assertion is unreachable. The focused tests currently pass (5/5), but only cover bare and top-level union forms.

@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/promotion-callable-types branch from 31a1cfe to 80ea733 Compare September 2, 2026 08:21
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Fixed. Probing showed Zend rejects callable while compiling the intersection type itself, in every declaration context, with "Type callable cannot be part of an intersection type" — and that this wins over the property-specific ban (callable|(Traversable&callable) reports the intersection error). So instead of teaching typeDeclContainsCallable() to return the wrong (property) diagnostic for intersections, a dedicated walk over nullable/union/intersection members now runs before the existing callable checks at all four guard sites (class constants, declared + promoted properties, interface constants, interface properties).

Your exact case flips from the gen_stub assert(!$type->isBuiltin) crash to Zend's diagnostic; bare intersections, second-DNF-member, promoted, constant and interface variants are all covered, and a callable-free DNF ((Countable&Traversable)|stdClass) still compiles — proving the assertion is unreachable without over-rejection. 7 new fixtures, 31/31 focused tests green.

@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/promotion-callable-types branch from 80ea733 to 7758dbe Compare September 2, 2026 09:20

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rebase this branch onto the latest master; #45, #52, #59, #63, and #66 have now been merged.

assertTypeDeclIntersectionsHaveNoCallable() is currently invoked only from property and class/interface constant paths. The same invalid type in a function parameter or return declaration still bypasses this check and can reach the later generator path:

function consume(Traversable&callable $value): void {}
function produce(): Traversable&callable {}

Zend rejects both while compiling the type with Type callable cannot be part of an intersection type.

Please move this rule into the common type-declaration validation path used by parameters, returns, properties, promoted properties, class constants, interface members, and closures, rather than maintaining repeated per-context calls. Add negative parameter and return tests, including callable inside a DNF member.

…roperty/constant types

Two promotion/type gaps against Zend (probed on 8.4.13):

- `__construct(public int ...$x)` was accepted and even registered
  the property before the variadic-position check ran. A variadic
  parameter collects its arguments into an array, so there is no single
  value to promote; Zend fatals with "Cannot declare variadic promoted
  property". The check now precedes the property registration.
- `callable` is a calling-scope-dependent type, so Zend forbids it in
  property types (declared, promoted, interface hooked) and class
  constant types (class and interface), bare or as a nullable/union
  member: "Property A::$x cannot have type ?callable" /
  "Class constant A::X cannot have type callable". Intersection
  members are left to the compound-type validation, which rejects
  every non-class standard type there.

`void`/`never` property and parameter types were already rejected by
parseTypeDecl ("The type `void`/`never` is allowed only for return
type") - verified, no change needed; union members are covered by the
compound-type validation.
typeDeclContainsCallable() deliberately skipped IntersectionType, so a
DNF-nested callable such as `public (Traversable&callable)|stdClass
$value;` sailed past the property checks and died in gen_stub on
assert(!$type->isBuiltin); a bare `Traversable&callable` property
compiled outright.

Zend rejects callable while compiling the intersection type itself,
with its own diagnostic ("Type callable cannot be part of an
intersection type", probed on 8.4.13), in every declaration context and
ahead of the property/constant-specific bans — `callable|(Traversable&
callable)` reports the intersection conflict, not the property one.

A dedicated assertTypeDeclIntersectionsHaveNoCallable() walk (nullable,
union, intersection members) now runs before the existing
typeDeclContainsCallable() checks in all contexts this branch guards:
class properties, promoted properties (both via addClassProperty),
typed class constants, and interface properties/constants. Tests cover
the bare intersection member, DNF in first and second union member,
the promoted and constant/interface variants, and a callable-free DNF
property that must keep compiling.
…-declaration path

assertTypeDeclIntersectionsHaveNoCallable() was invoked only from the
property and class/interface-constant paths, so the same invalid type
in a function parameter or return declaration bypassed the check and
reached the later generator path:

    function consume(Traversable&callable $value): void {}
    function produce(): Traversable&callable {}

Zend rejects both while compiling the type itself ("Type callable
cannot be part of an intersection type", probed on 8.4.13), in every
declaration context.

The walk now lives in parseTypeDecl(), the declaration funnel behind
resolveTypeDecl() that parameters, returns, properties, promoted
properties, class and interface constants, and interface hooked
properties already flow through; the per-context calls are gone, and
the diagnostic points at the offending intersection member. Closure
and arrow-function signatures resolved no full type node anywhere, so
doGenClosure() now routes them through the same funnel - except bare
class names, which the native-object walk there already resolves (and,
inside trait methods, rewrites) via parseTypeDecl().

The property and constant paths resolve the declaration before
applying their own bare/nullable/union callable bans, so a type like
`callable|(Traversable&callable)` keeps reporting the intersection
conflict first, as Zend does.

New negative tests: parameter and return intersections, callable in a
DNF parameter member, and closure parameter and return intersections
(each probed against Zend 8.4.13); positive tests keep bare `callable`
parameters and callable-free DNF properties compiling.
@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/promotion-callable-types branch from 7758dbe to 64c7cc4 Compare September 2, 2026 13:18
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Rebased onto current master. The rule now lives on the common path:

  • assertTypeDeclIntersectionsHaveNoCallable() moved into parseTypeDecl() (behind resolveTypeDecl()), the funnel that parameters, returns, properties, promoted properties, class constants, interface constants and interface hooked properties all reach; the four per-context calls are removed. The diagnostic text matches Zend: Type callable cannot be part of an intersection type, anchored at the offending member node.
  • Closures and arrow functions previously never resolved their full type nodes, so doGenClosure() now routes their parameter and return declarations through resolveTypeDecl() as well.
  • The separate bare/nullable/union callable property/constant bans remain distinct rules (as in Zend), but resolveTypeDecl() is hoisted above them so callable|(Traversable&callable) reports the intersection conflict first, matching Zend's precedence.

Tests (each verified against Zend 8.4.13 first): negative parameter Traversable&callable, negative return, DNF member (Traversable&callable)|stdClass in a parameter, closure parameter and closure return; positive bare-callable parameter stays accepted.

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.

2 participants