Skip to content

Add TypeSpecifierContext::getConditionType() and move count/strlen narrowing to extensions#5781

Closed
ondrejmirtes wants to merge 6 commits into
2.2.xfrom
worktree-condition-type
Closed

Add TypeSpecifierContext::getConditionType() and move count/strlen narrowing to extensions#5781
ondrejmirtes wants to merge 6 commits into
2.2.xfrom
worktree-condition-type

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

Problem

Type-specifying extensions (FunctionTypeSpecifyingExtension etc.) could only learn the boolean shape of the evaluated condition via TypeSpecifierContexttrue()/truthy()/false()/falsey()/null(). They could not react to a more specific numeric condition like count($x) >= 2, so that richer narrowing had to live hardcoded inside TypeSpecifier.

What this does

New TypeSpecifierContext::getConditionType(): ?Type (+ withConditionType())

Reports the constraint on the analyzed function's return value for the branch being evaluated — e.g. for count($x) >= 2 the count() extension now receives IntegerRangeType(2, max).

  • Additive / fully backward compatible. The ?int bitmask is untouched; a parallel ?Type $conditionType field is added. true()/truthy()/false()/falsey()/null()/negate() and the @api CONTEXT_* constants behave identically.
  • The condition type is a one-directional hint valid for the exact (bitmask, type) pair handed to an extension; negate() drops it (so extensions that negate fall back to bitmask-only behavior).

Proof of concept: move hardcoded narrowing out of TypeSpecifier

  • count()/sizeof() array-size narrowing moves into a new #[AutowiredService] CountFuncCallTypeSpecifier, used by CountFunctionTypeSpecifyingExtension (driven by getConditionType()) and by TypeSpecifier for the residual sibling-offset inference and count($a) === N cases. Internals are not exposed as public @internal on TypeSpecifier.
  • strlen()/mb_strlen() non-empty-string / non-falsy-string narrowing moves into StrlenFunctionTypeSpecifyingExtension, driven by getConditionType().
  • TypeSpecifier keeps only the generic "compute the integer size constraint and dispatch" logic (resolveResultSizeType() + withConditionType()). Net ~210 lines removed from TypeSpecifier.

preg_match — intentionally not migrated

preg_match has no hardcoded type specification in TypeSpecifier — only a comparison → === 1 normalization rewrite; its $matches narrowing already lives entirely in its extension. Routing it through getConditionType() changed the $matches shape in the not-matched/error branches (because preg_match returns int|false), so it was left as-is. Can be revisited as a follow-up.

Verification

  • 1753 tests pass: full NodeScopeResolverTest (all nsrt assertType files), TypeSpecifierTest, TypeSpecifierContextTest (with new tests for the new methods).
  • make phpstan (self-analysis) and make cs: clean.
  • Zero assertType expectation churn — the count/strlen migrations are behavior-preserving refactors.

🤖 Generated with Claude Code

ondrejmirtes and others added 6 commits May 29, 2026 15:12
…en narrowing to extensions

Type-specifying extensions could only see the truthy/falsey/true/false bucket
of the evaluated condition. They now also get getNarrowedReturnType(): ?Type, the
constraint on the analyzed function's return value for the branch being
evaluated (e.g. IntegerRangeType(2, max) for `count($x) >= 2`). The bitmask is
kept untouched; the narrowed return type is an additive, one-directional hint that
negate() drops.

As a proof of concept, the hardcoded count()/sizeof() and strlen()/mb_strlen()
comparison narrowing moves out of TypeSpecifier into their extensions:
- the count array-size narrowing moves into the new CountFuncCallTypeSpecifier
  service, used by CountFunctionTypeSpecifyingExtension and TypeSpecifier;
- TypeSpecifier keeps only the generic size-constraint computation
  (resolveResultSizeType) and dispatches via withConditionType();
- StrlenFunctionTypeSpecifyingExtension derives non-empty-string/non-falsy-string
  from the narrowed return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tension

TypeSpecifier dispatches a function call compared against a value to its type-specifying
extensions, passing the compared type as the narrowed return type (TypeSpecifierContext::
getNarrowedReturnType()). Only extensions that require the narrowed return type (they would not
support the bare call) take part, so plain truthy/falsey extensions are untouched - no
marker interface needed. get_class()/get_debug_type() === class-string narrowing now
lives in GetClassFunctionTypeSpecifyingExtension.
gettype($a) === 'string' (and the other gettype names) narrowing now lives in
GettypeFunctionTypeSpecifyingExtension, dispatched via the narrowed return type from
specifyTypesForConstantStringBinaryExpression instead of being hardcoded in TypeSpecifier.
get_parent_class($a) === Foo::class narrowing now lives in
GetParentClassFunctionTypeSpecifyingExtension, dispatched via the narrowed return type instead of
being hardcoded in TypeSpecifier.
trim($s) !== '' (and ltrim/rtrim/chop/mb_*) non-empty-string narrowing now lives in
TrimFunctionTypeSpecifyingExtension, dispatched via the narrowed return type instead of being
hardcoded in TypeSpecifier.
…rowing to an extension

array_key_first($a) !== null etc. non-empty-array narrowing now lives in
ArrayKeyFirstLastFunctionTypeSpecifyingExtension, dispatched via the narrowed return type instead
of being hardcoded in TypeSpecifier.
@ondrejmirtes ondrejmirtes force-pushed the worktree-condition-type branch from 6905f0c to ca566c3 Compare May 29, 2026 13:14
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.

1 participant