diff --git a/src/Parser/ArrayOffsetNormalizingVisitor.php b/src/Parser/ArrayOffsetNormalizingVisitor.php new file mode 100644 index 00000000000..57b8076aceb --- /dev/null +++ b/src/Parser/ArrayOffsetNormalizingVisitor.php @@ -0,0 +1,232 @@ +dim === null) { + return null; + } + + $node->dim = $this->normalize($node->dim); + + return null; + } + + private function normalize(Expr $dim): Expr + { + if ($dim instanceof String_) { + $this->canonicalizeStringKind($dim); + + return $dim; + } + + if ($dim instanceof Int_) { + $dim->setAttribute('kind', Int_::KIND_DEC); + + return $dim; + } + + if (!$dim instanceof InterpolatedString && !$dim instanceof Concat) { + return $dim; + } + + $operands = $this->mergeAdjacentStrings($this->flatten($dim)); + if (count($operands) === 1) { + $operand = $operands[0]; + if ($operand instanceof String_) { + // The whole offset is a constant string built out of pieces, + // e.g. `'a' . 'b'` - spell it the way `$a['ab']` is spelled. + return $operand; + } + + // A one-part interpolation is a string cast, which the part alone is + // not: `"$k"` and `$k` are the same offset only when `$k` is already + // a string. Leave the cast in place and only pin its spelling. + $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); + + return $dim; + } + + $concat = $operands[0]; + for ($i = 1; $i < count($operands); $i++) { + $concat = new Concat($concat, $operands[$i], $this->spanAttributes($concat, $operands[$i])); + } + + return $concat; + } + + /** + * Collects the operands of a concatenation, no matter which of the two + * syntaxes - or which nesting of them - built it. String concatenation is + * associative, so the flat list describes the same value as the tree. + * + * @return list + */ + private function flatten(Expr $expr): array + { + if ($expr instanceof Concat) { + $operands = []; + foreach ([$expr->left, $expr->right] as $side) { + foreach ($this->flatten($side) as $operand) { + $operands[] = $operand; + } + } + + return $operands; + } + + if ($expr instanceof InterpolatedString) { + $operands = []; + foreach ($expr->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $operands[] = $this->createString($part->value, $part->getAttributes()); + continue; + } + + foreach ($this->flatten($part) as $operand) { + $operands[] = $operand; + } + } + + return $operands; + } + + if ($expr instanceof Int_) { + $expr->setAttribute('kind', Int_::KIND_DEC); + } elseif ($expr instanceof String_) { + $this->canonicalizeStringKind($expr); + } + + return [$expr]; + } + + /** + * @param list $operands + * @return non-empty-list + */ + private function mergeAdjacentStrings(array $operands): array + { + $merged = []; + foreach ($operands as $operand) { + $last = $merged === [] ? null : $merged[array_key_last($merged)]; + if (!$operand instanceof String_ || !$last instanceof String_) { + $merged[] = $operand; + continue; + } + + $merged[array_key_last($merged)] = $this->createString( + $last->value . $operand->value, + $this->spanAttributes($last, $operand), + ); + } + + if ($merged === []) { + // An interpolation always has at least one part, but an empty one + // would still describe the empty string. + return [$this->createString('', [])]; + } + + return $merged; + } + + /** + * @param array $attributes + */ + private function createString(string $value, array $attributes): String_ + { + $string = new String_($value, $attributes); + $this->canonicalizeStringKind($string); + + return $string; + } + + /** + * Single quotes normally, double quotes when the value holds control + * characters - the same canonical form as ConstantStringType::export(), and + * the one that keeps the expression key free of newlines. + */ + private function canonicalizeStringKind(String_ $string): void + { + $string->setAttribute( + 'kind', + preg_match('/[\x00-\x1f]/', $string->value) === 1 + ? String_::KIND_DOUBLE_QUOTED + : String_::KIND_SINGLE_QUOTED, + ); + } + + /** + * Keeps a synthesized node pointing at the source it stands for, so that + * error lines stay put and format-preserving printing reproduces the + * original spelling instead of the canonical one. + * + * @return array + */ + private function spanAttributes(Node $start, Node $end): array + { + $attributes = []; + foreach (['startLine', 'startTokenPos', 'startFilePos'] as $attribute) { + if (!$start->hasAttribute($attribute)) { + continue; + } + + $attributes[$attribute] = $start->getAttribute($attribute); + } + + foreach (['endLine', 'endTokenPos', 'endFilePos'] as $attribute) { + if (!$end->hasAttribute($attribute)) { + continue; + } + + $attributes[$attribute] = $end->getAttribute($attribute); + } + + return $attributes; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15060.php b/tests/PHPStan/Analyser/nsrt/bug-15060.php new file mode 100644 index 00000000000..ea7e378cafb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15060.php @@ -0,0 +1,113 @@ +', $searchParams['test']); + assertType('array', $searchParams["test"]); + } + + if (is_array($searchParams["test"]) && $searchParams["test"]) { + assertType('non-empty-array', $searchParams['test']); + assertType('non-empty-array', $searchParams["test"]); + } + } +} + +function otherStringSpellings($m): void +{ + if (is_array($m['test']) && $m['test']) { + assertType('non-empty-array', $m['test']); + assertType('non-empty-array', $m["test"]); + assertType('non-empty-array', $m["\x74est"]); + assertType('non-empty-array', $m[<<<'NOWDOC' + test + NOWDOC]); + assertType('non-empty-array', $m[<<', $m[1]); + assertType('non-empty-array', $m[0x1]); + assertType('non-empty-array', $m[01]); + assertType('non-empty-array', $m[0b1]); + assertType('mixed', $m[10]); + } +} + +function interpolatedSpellings($m, string $k): void +{ + if (is_array($m["x$k"]) && $m["x$k"]) { + assertType('non-empty-array', $m["x$k"]); + assertType('non-empty-array', $m["x{$k}"]); + assertType('non-empty-array', $m[<<', $m["$k.value"]); + assertType('non-empty-array', $m[$k . ".value"]); + assertType('non-empty-array', $m[$k . '.value']); + assertType('non-empty-array', $m["{$k}.value"]); + assertType('non-empty-array', $m[<<', $m[$k . '.value']); + } +} + +function concatOfConstantStrings($m): void +{ + if (is_array($m['a' . 'b']) && $m['a' . 'b']) { + assertType('non-empty-array', $m['ab']); + assertType('non-empty-array', $m["ab"]); + assertType('non-empty-array', $m['a' . 'b']); + } +} + +function concatNesting($m, string $a, string $b): void +{ + if (is_array($m[$a . $b . '.value']) && $m[$a . $b . '.value']) { + assertType('non-empty-array', $m[$a . ($b . '.value')]); + assertType('non-empty-array', $m["$a$b.value"]); + assertType('non-empty-array', $m["$a" . "$b" . '.value']); + } +} + +function singlePartInterpolationIsAStringCast($m, int $i): void +{ + if (is_array($m["$i"]) && $m["$i"]) { + assertType('non-empty-array', $m["$i"]); + assertType('non-empty-array', $m["{$i}"]); + // "$i" casts to string, $i does not - the offsets only coincide + // because PHP normalizes integer-like string keys. + assertType('mixed', $m[$i]); + } +} diff --git a/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php b/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php new file mode 100644 index 00000000000..d2b2e902a61 --- /dev/null +++ b/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php @@ -0,0 +1,73 @@ +parseString(sprintf('expr instanceof ArrayDimFetch) { + throw new ShouldNotHappenException(); + } + + $printer = self::getContainer()->getByType(ExprPrinter::class); + $this->assertSame($expectedKey, $printer->printExpr($stmts[0]->expr)); + } + +}