Skip to content

Commit 4fdc652

Browse files
phpstan-botclaude
authored andcommitted
Drop non-decimal-int-string inference from regex matching
Removes the negatedClassExcludesAllDigits() helper (and the now-unused $inNegativeClass parameter / non-decimal walk-result plumbing) per review. Regex capturing groups that can only match non-digits are no longer narrowed to non-decimal-int-string; decimal-int-string inference for digit groups is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent acd30a1 commit 4fdc652

3 files changed

Lines changed: 19 additions & 128 deletions

File tree

src/Type/Regex/RegexGroupParser.php

Lines changed: 16 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,9 @@
2424
use PHPStan\Type\UnionType;
2525
use function array_key_exists;
2626
use function array_values;
27-
use function chr;
2827
use function count;
2928
use function in_array;
3029
use function is_int;
31-
use function ord;
3230
use function preg_replace;
3331
use function rtrim;
3432
use function sscanf;
@@ -124,7 +122,6 @@ public function parseGroups(string $regex): ?RegexAstWalkResult
124122
false,
125123
$modifiers,
126124
RegexGroupWalkResult::createEmpty(),
127-
false,
128125
);
129126

130127
if (!$subjectAsGroupResult->mightContainEmptyStringLiteral() && !$this->containsEscapeK($ast)) {
@@ -412,7 +409,6 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p
412409
false,
413410
$patternModifiers,
414411
RegexGroupWalkResult::createEmpty(),
415-
false,
416412
);
417413

418414
if ($maybeConstant && $walkResult->getOnlyLiterals() !== null && $walkResult->getOnlyLiterals() !== []) {
@@ -438,15 +434,6 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p
438434
return new UnionType([new ConstantStringType(''), $result]);
439435
}
440436
return $result;
441-
} elseif ($walkResult->isNonDecimalInteger()->yes()) {
442-
$accessories = [new StringType(), new AccessoryDecimalIntegerStringType(true)];
443-
if ($walkResult->isNonFalsy()->yes()) {
444-
$accessories[] = new AccessoryNonFalsyStringType();
445-
} elseif ($walkResult->isNonEmpty()->yes()) {
446-
$accessories[] = new AccessoryNonEmptyStringType();
447-
}
448-
449-
return new IntersectionType($accessories);
450437
} elseif ($walkResult->isNonFalsy()->yes()) {
451438
return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]);
452439
} elseif ($walkResult->isNonEmpty()->yes()) {
@@ -483,7 +470,6 @@ private function walkGroupAst(
483470
bool $inClass,
484471
string $patternModifiers,
485472
RegexGroupWalkResult $walkResult,
486-
bool $inNegativeClass,
487473
): RegexGroupWalkResult
488474
{
489475
$children = $ast->getChildren();
@@ -555,27 +541,19 @@ private function walkGroupAst(
555541
$walkResult = $walkResult->onlyLiterals($onlyLiterals);
556542

557543
if ($literalValue !== null) {
558-
if (!$inNegativeClass) {
559-
if (Strings::match($literalValue, '/^\d+$/') !== null) {
560-
if ($walkResult->isDecimalInteger()->maybe()) {
561-
$walkResult = $walkResult->decimalInteger(TrinaryLogic::createYes());
562-
}
563-
} elseif (
564-
$literalValue === '-'
565-
&& $walkResult->isDecimalInteger()->maybe()
566-
&& !$walkResult->hasSeenDecimalIntegerSign()
567-
) {
568-
// a single leading minus sign keeps the string a decimal integer (e.g. "-1")
569-
$walkResult = $walkResult->seenDecimalIntegerSign(true);
570-
} else {
571-
$walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo());
572-
}
573-
574-
// a literal token outside a negative class might be (part of) a decimal integer,
575-
// so we can no longer guarantee the absence of one
576-
if ($literalValue !== '') {
577-
$walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo());
544+
if (Strings::match($literalValue, '/^\d+$/') !== null) {
545+
if ($walkResult->isDecimalInteger()->maybe()) {
546+
$walkResult = $walkResult->decimalInteger(TrinaryLogic::createYes());
578547
}
548+
} elseif (
549+
$literalValue === '-'
550+
&& $walkResult->isDecimalInteger()->maybe()
551+
&& !$walkResult->hasSeenDecimalIntegerSign()
552+
) {
553+
// a single leading minus sign keeps the string a decimal integer (e.g. "-1")
554+
$walkResult = $walkResult->seenDecimalIntegerSign(true);
555+
} else {
556+
$walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo());
579557
}
580558

581559
if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') {
@@ -595,7 +573,6 @@ private function walkGroupAst(
595573
$nonEmpty = TrinaryLogic::createYes();
596574
$nonFalsy = TrinaryLogic::createYes();
597575
$decimalInteger = TrinaryLogic::createYes();
598-
$nonDecimalInteger = TrinaryLogic::createYes();
599576
foreach ($children as $child) {
600577
$childResult = $this->walkGroupAst(
601578
$child,
@@ -605,15 +582,12 @@ private function walkGroupAst(
605582
->nonEmpty(TrinaryLogic::createMaybe())
606583
->nonFalsy(TrinaryLogic::createMaybe())
607584
->decimalInteger(TrinaryLogic::createMaybe())
608-
->nonDecimalInteger(TrinaryLogic::createMaybe())
609585
->seenDecimalIntegerSign(false),
610-
$inNegativeClass,
611586
);
612587

613588
$nonEmpty = $nonEmpty->and($childResult->isNonEmpty());
614589
$nonFalsy = $nonFalsy->and($childResult->isNonFalsy());
615590
$decimalInteger = $decimalInteger->and($childResult->isDecimalInteger());
616-
$nonDecimalInteger = $nonDecimalInteger->and($childResult->isNonDecimalInteger());
617591

618592
if ($newLiterals === null) {
619593
continue;
@@ -632,23 +606,14 @@ private function walkGroupAst(
632606
->onlyLiterals($newLiterals)
633607
->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty))
634608
->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy))
635-
->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger))
636-
->nonDecimalInteger($walkResult->isNonDecimalInteger()->and($nonDecimalInteger));
609+
->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger));
637610
}
638611

639-
// a negative class never matches a decimal integer on its own; when it excludes every
640-
// digit it can only match non-digit characters, so the result is a non-decimal-int-string
612+
// [^0-9] should not parse as decimal-int-string, and [^list-everything-but-numbers] is technically
613+
// doable but really silly compared to just \d so we can safely assume the string is not a decimal
614+
// integer for negative classes
641615
if ($ast->getId() === '#negativeclass') {
642616
$walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo());
643-
if ($this->negatedClassExcludesAllDigits($ast)) {
644-
if ($walkResult->isNonDecimalInteger()->maybe()) {
645-
$walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createYes());
646-
}
647-
} else {
648-
$walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo());
649-
}
650-
651-
$inNegativeClass = true;
652617
}
653618

654619
foreach ($children as $child) {
@@ -657,50 +622,12 @@ private function walkGroupAst(
657622
$inClass,
658623
$patternModifiers,
659624
$walkResult,
660-
$inNegativeClass,
661625
);
662626
}
663627

664628
return $walkResult;
665629
}
666630

667-
private function negatedClassExcludesAllDigits(TreeNode $negativeClass): bool
668-
{
669-
$excludedDigits = [];
670-
foreach ($negativeClass->getChildren() as $child) {
671-
if ($child->getId() === '#range') {
672-
$from = $child->getChild(0)->getValueValue();
673-
$to = $child->getChild(1)->getValueValue();
674-
if (strlen($from) === 1 && strlen($to) === 1) {
675-
for ($ord = ord($from); $ord <= ord($to); $ord++) {
676-
$char = chr($ord);
677-
if (Strings::match($char, '/^\d$/') === null) {
678-
continue;
679-
}
680-
$excludedDigits[$char] = true;
681-
}
682-
}
683-
} elseif ($child->getId() === 'token') {
684-
$value = $child->getValueValue();
685-
if ($child->getValueToken() === 'character_type' && $value === '\d') {
686-
for ($digit = 0; $digit <= 9; $digit++) {
687-
$excludedDigits[(string) $digit] = true;
688-
}
689-
} elseif (Strings::match($value, '/^\d$/') !== null) {
690-
$excludedDigits[$value] = true;
691-
}
692-
}
693-
}
694-
695-
for ($digit = 0; $digit <= 9; $digit++) {
696-
if (!array_key_exists((string) $digit, $excludedDigits)) {
697-
return false;
698-
}
699-
}
700-
701-
return true;
702-
}
703-
704631
private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool
705632
{
706633
if ($node->getId() === '#quantification') {

src/Type/Regex/RegexGroupWalkResult.php

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ public function __construct(
1717
private TrinaryLogic $isNonEmpty,
1818
private TrinaryLogic $isNonFalsy,
1919
private TrinaryLogic $isDecimalInteger,
20-
private TrinaryLogic $isNonDecimalInteger,
2120
private bool $seenDecimalIntegerSign,
2221
)
2322
{
@@ -31,7 +30,6 @@ public static function createEmpty(): self
3130
TrinaryLogic::createMaybe(),
3231
TrinaryLogic::createMaybe(),
3332
TrinaryLogic::createMaybe(),
34-
TrinaryLogic::createMaybe(),
3533
false,
3634
);
3735
}
@@ -44,7 +42,6 @@ public function inOptionalQuantification(bool $inOptionalQuantification): self
4442
$this->isNonEmpty,
4543
$this->isNonFalsy,
4644
$this->isDecimalInteger,
47-
$this->isNonDecimalInteger,
4845
$this->seenDecimalIntegerSign,
4946
);
5047
}
@@ -60,7 +57,6 @@ public function onlyLiterals(?array $onlyLiterals): self
6057
$this->isNonEmpty,
6158
$this->isNonFalsy,
6259
$this->isDecimalInteger,
63-
$this->isNonDecimalInteger,
6460
$this->seenDecimalIntegerSign,
6561
);
6662
}
@@ -73,7 +69,6 @@ public function nonEmpty(TrinaryLogic $nonEmpty): self
7369
$nonEmpty,
7470
$this->isNonFalsy,
7571
$this->isDecimalInteger,
76-
$this->isNonDecimalInteger,
7772
$this->seenDecimalIntegerSign,
7873
);
7974
}
@@ -86,7 +81,6 @@ public function nonFalsy(TrinaryLogic $nonFalsy): self
8681
$this->isNonEmpty,
8782
$nonFalsy,
8883
$this->isDecimalInteger,
89-
$this->isNonDecimalInteger,
9084
$this->seenDecimalIntegerSign,
9185
);
9286
}
@@ -100,21 +94,6 @@ public function decimalInteger(TrinaryLogic $decimalInteger): self
10094
$this->isNonEmpty,
10195
$this->isNonFalsy,
10296
$decimalInteger,
103-
$this->isNonDecimalInteger,
104-
$this->seenDecimalIntegerSign,
105-
);
106-
}
107-
108-
/** A non-decimal integer string is guaranteed to contain no decimal integer (e.g. it has no digits at all). */
109-
public function nonDecimalInteger(TrinaryLogic $nonDecimalInteger): self
110-
{
111-
return new self(
112-
$this->inOptionalQuantification,
113-
$this->onlyLiterals,
114-
$this->isNonEmpty,
115-
$this->isNonFalsy,
116-
$this->isDecimalInteger,
117-
$nonDecimalInteger,
11897
$this->seenDecimalIntegerSign,
11998
);
12099
}
@@ -127,7 +106,6 @@ public function seenDecimalIntegerSign(bool $seenDecimalIntegerSign): self
127106
$this->isNonEmpty,
128107
$this->isNonFalsy,
129108
$this->isDecimalInteger,
130-
$this->isNonDecimalInteger,
131109
$seenDecimalIntegerSign,
132110
);
133111
}
@@ -174,11 +152,6 @@ public function isDecimalInteger(): TrinaryLogic
174152
return $this->isDecimalInteger;
175153
}
176154

177-
public function isNonDecimalInteger(): TrinaryLogic
178-
{
179-
return $this->isNonDecimalInteger;
180-
}
181-
182155
public function hasSeenDecimalIntegerSign(): bool
183156
{
184157
return $this->seenDecimalIntegerSign;

tests/PHPStan/Analyser/nsrt/bug-14750.php

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ function pregMatchDecimalIntStringTypeMatches(string $x): void
2222
assertType('decimal-int-string', $matches[1]);
2323
}
2424

25+
// a negated digit class does not match a decimal integer
2526
if (preg_match('/^([^0-9])$/', $x, $matches)) {
26-
assertType('non-decimal-int-string&non-empty-string', $matches[1]);
27+
assertType('non-empty-string', $matches[1]);
2728
}
2829
}
2930

@@ -49,18 +50,8 @@ function edgeCases(string $x): void
4950
assertType('non-empty-string', $matches[1]);
5051
}
5152

52-
// quantified negated digit class only matches non-digits
53+
// a negated digit class never yields a decimal integer
5354
if (preg_match('/^([^0-9]+)$/', $x, $matches)) {
54-
assertType('non-decimal-int-string&non-empty-string', $matches[1]);
55-
}
56-
57-
// negated class that does not exclude every digit can still match a decimal integer
58-
if (preg_match('/^([^1-4])$/', $x, $matches)) {
5955
assertType('non-empty-string', $matches[1]);
6056
}
61-
62-
// alternation of a digit and a negated digit class
63-
if (preg_match('/^(\d|[^0-9])$/', $x, $matches)) {
64-
assertType('decimal-int-string|(non-decimal-int-string&non-empty-string)', $matches[1]);
65-
}
6657
}

0 commit comments

Comments
 (0)