🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Treat a regex alternation as maybe-empty when any branch is maybe-empty in isMaybeEmptyNode - #5851

Merged
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wnglkq0
Jun 12, 2026
Merged

Treat a regex alternation as maybe-empty when any branch is maybe-empty in isMaybeEmptyNode#5851
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wnglkq0

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

preg_match shape inference inferred non-falsy-string instead of string for the
whole match and enclosing capture groups of patterns that nest a captured optional
subpattern, e.g. '(.?(.|$))' (where the outer (/) are the regex delimiters, so the
actual pattern is .?(.|$)). The (.|$) alternation can match the empty string at $,
so the whole match may be empty and should be string.

Changes

  • src/Type/Regex/RegexGroupParser.php: add an explicit #alternation case to
    isMaybeEmptyNode(). An alternation is now considered maybe-empty when any of its
    branches is maybe-empty, and non-falsy only when every branch is non-falsy.
  • tests/PHPStan/Analyser/nsrt/bug-14809.php: regression test covering the reported
    reproducers plus a guard that an all-non-empty alternation ((a(b|c))) still yields a
    non-falsy string.

Root cause

isMaybeEmptyNode() is used by walkGroupAst() while counting the "meaningful tokens"
of a concatenation to decide whether the match is non-empty / non-falsy. The method only
had concatenation semantics: it recursed over a node's children and returned "not empty"
(and propagated isNonFalsy) as soon as a single child was non-empty. For an
#alternation node this is exactly backwards — an alternation is empty-capable when
any branch is empty-capable. So (.|$) was classified as a non-falsy, non-empty token;
inside the surrounding .?(.|$) concatenation that single non-falsy token short-circuited
the loop and marked the whole match (and the enclosing capture group) as
non-falsy-string.

The fix gives #alternation its own branch with the correct quantifier semantics
(any-branch-empty ⇒ maybe empty; all-branches-non-falsy ⇒ non-falsy). This corrects both
the full-match subject type and the capture-group types, since both flow through
walkGroupAst().

Test

tests/PHPStan/Analyser/nsrt/bug-14809.php asserts:

  • '(.?(.|$))'array{string, string} (reported issue)
  • '((.?(.|$)))'array{string, string, string} (reported issue)
  • '((.?(.|.?)))'array{string, string, string} (simplified reproducer)
  • '~(a(b|c))~'array{non-falsy-string, 'b'|'c', 'b'|'c'} (guard: all-non-empty
    alternation must remain non-falsy)

The test fails on the first three assertions before the fix and passes after. The
existing preg_match / preg_split / preg_replace shape tests and the full
NodeScopeResolverTest suite continue to pass.

Probed but intentionally not changed

~.(.|$)~ still infers the full match as non-falsy-string. This is a separate,
pre-existing over-approximation (the . any-char metacharacter is tokenized as a literal
'.', which getLiteralValue() returns as a non-falsy string) — independent of the
alternation maybe-empty bug fixed here — and would change many existing expectations, so
it is left out of this change.

Fixes phpstan/phpstan#14809


// guard: an alternation whose branches are all non-empty stays non-falsy
if (preg_match('~(a(b|c))~', $s, $m)) {
assertType("array{non-falsy-string, 'b'|'c', 'b'|'c'}", $m);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

these expectations are wrong.

Suggested change
assertType("array{non-falsy-string, 'b'|'c', 'b'|'c'}", $m);
assertType("array{non-falsy-string, 'ab'|'ac', 'b'|'c'}", $m);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've addressed the review. Here's a summary of what I did:

The review

staabm pointed out that the test expectation for ~(a(b|c))~ was wrong: group 1 (a(b|c)) was asserted as 'b'|'c' but should be 'ab'|'ac' (the literal a prefix belongs to that capture group's value).

Investigation

I confirmed PHPStan actually inferred 'b'|'c' for group 1 — so the reviewer's suggested expectation wasn't just a typo to swap in; it exposed a real, separate bug. Probing adjacent cases pinpointed it:

  • (a(b|c))'b'|'c' ❌ (prefix dropped)
  • (a(b|c)d)'bd'|'cd' ❌ (prefix dropped, suffix kept)
  • ((b|c)a)'ba'|'ca' ✓ (suffix worked)
  • (a(b|c)(d|e))'d'|'e' ❌ (everything before the last alternation dropped)

The #alternation branch in walkGroupAst() discarded the literal prefix accumulated so far (it started fresh with $newLiterals = []), while suffix tokens after an alternation correctly appended.

Fix

In RegexGroupParser::walkGroupAst(), the #alternation branch now cross-products the incoming prefix literals with each branch's literals (and correctly falls back to a non-constant string when the prefix is non-constant, e.g. (.(b|c)) now yields non-falsy-string instead of a bogus 'b'|'c').

After the fix:

  • (a(b|c))'ab'|'ac' ✓ (matches the reviewer's suggestion)
  • (a(b|c)(d|e))'abd'|'abe'|'acd'|'ace'
  • The PR's original (.?(.|$))array{string, string} still holds ✓

Verification

  • Updated the test expectation to array{non-falsy-string, 'ab'|'ac', 'b'|'c'}.
  • Confirmed the test fails for the right reason without the source fix (expected 'ab'|'ac', actual 'b'|'c') and passes with it.
  • make tests (12668 tests), make phpstan (no errors), and make cs/lint all pass.

Committed and pushed to the PR branch.

@staabm
staabm requested a review from VincentLanglet June 12, 2026 09:19
phpstan-bot and others added 3 commits June 12, 2026 11:19
…ty in `isMaybeEmptyNode`

- Add an explicit `#alternation` case to `RegexGroupParser::isMaybeEmptyNode()`: an
  alternation is maybe-empty if any of its branches is maybe-empty, and only
  non-falsy if every branch is non-falsy.
- Previously alternation nodes fell through to the concatenation-style loop, which
  reported a node as non-empty/non-falsy as soon as a single branch was non-empty.
  For patterns like `.?(.|$)` this made the `(.|$)` group count as a non-falsy token
  inside the surrounding concatenation, so the whole match (and any enclosing capture
  group) was inferred as `non-falsy-string` instead of `string`, even though the `$`
  branch matches the empty string.
- Affects both the full-match subject type and capturing-group types, since both are
  derived from `walkGroupAst()` / `isMaybeEmptyNode()`.
…gex group types

When a capturing group concatenates a literal prefix before a nested
alternation, e.g. "(a(b|c))", the prefix accumulated so far was dropped
when walking the #alternation node, yielding "b"|"c" instead of "ab"|"ac".
Cross-product the incoming prefix literals with each branch's literals
(and give up to a non-constant string when the prefix is non-constant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-wnglkq0 branch from c1cfd8c to b788f79 Compare June 12, 2026 09:19
@staabm
staabm merged commit 1759981 into phpstan:2.2.x Jun 12, 2026
666 of 671 checks passed
@staabm
staabm deleted the create-pull-request/patch-wnglkq0 branch June 12, 2026 09:50
@staabm staabm mentioned this pull request Jun 12, 2026
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.

Nested captured optional subpatterns result in non-falsy-string instead of string

3 participants