Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Command/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Command;

use DateTimeImmutable;
use OndraM\CiDetector\CiDetector;
use Override;
use PHPStan\Analyser\InternalError;
Expand Down Expand Up @@ -35,6 +36,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
use Throwable;
Expand Down Expand Up @@ -63,6 +65,7 @@
use function stream_get_contents;
use function strlen;
use function substr;
use function time;
use const PATHINFO_BASENAME;
use const PATHINFO_EXTENSION;

Expand Down Expand Up @@ -140,6 +143,34 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($output instanceof ConsoleOutputInterface) {
$errorOutput = $output->getErrorOutput();
$errorOutput->writeln('');
$errorOutput->writeln("⚠️ You're running an old version of PHPStan.️");
$errorOutput->writeln('');
$errorOutput->writeln('The last release in the 1.12.x series with new features');

$lastRelease = new DateTimeImmutable('2025-07-17 00:00:00');
$daysSince = (time() - $lastRelease->getTimestamp()) / 60 / 60 / 24;
$errorOutput->writeln('and bugfixes was released on <fg=red>July 17th 2025</>,');
$errorOutput->writeln(sprintf('that\'s <fg=red>%d days ago.</>', (int) $daysSince));
$errorOutput->writeln('');

$errorOutput->writeln('Since then more than <fg=cyan>65 new PHPStan versions</> were released');
$errorOutput->writeln('with hundreds of new features, bugfixes, and other');
$errorOutput->writeln('quality of life improvements.');
$errorOutput->writeln('');

$errorOutput->writeln("To learn about what you're missing out on, check out");
$errorOutput->writeln('this blog with articles about the latest major releases:');
$errorOutput->writeln('<options=underscore>https://phpstan.org/blog</>');
$errorOutput->writeln('');

$errorOutput->writeln('Upgrade today to <fg=green>PHPStan 2.2 or newer</> by using');
$errorOutput->writeln('<fg=cyan>"phpstan/phpstan": "^2.2"</> in your <fg=cyan>composer.json</>.');
$errorOutput->writeln('');
}

$paths = $input->getArgument('paths');
$memoryLimit = $input->getOption('memory-limit');
$autoloadFile = $input->getOption('autoload-file');
Expand Down
83 changes: 83 additions & 0 deletions src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
use PHPStan\Type\TypeUtils;
use PHPStan\Type\UnionType;
use function count;
use function explode;
use function max;
use const PHP_INT_MAX;

#[AutowiredService]
final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
Expand All @@ -45,6 +47,12 @@ final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunction
'str_ends_with',
];

/**
* How many delimiter/string/limit combinations may be evaluated when
* constant-folding the call before giving up on the exact result.
*/
private const CONSTANT_COMBINATION_LIMIT = 16;

public function __construct(private PhpVersion $phpVersion)
{
}
Expand Down Expand Up @@ -90,6 +98,12 @@ public function getTypeFromFunctionCall(
}

$limitType = isset($args[2]) ? $scope->getType($args[2]->value) : null;

$constantType = $this->createConstantSplitType($delimiterType, $stringType, $limitType);
if ($constantType !== null) {
return $constantType;
}

$delimiterGuaranteedPresent = $this->isDelimiterGuaranteedPresent($args, $scope);

if ($this->isSingleElementLimit($limitType)) {
Expand Down Expand Up @@ -144,6 +158,75 @@ private function isDelimiterGuaranteedPresent(array $args, Scope $scope): bool
return false;
}

/**
* The exact result of the split when the delimiter, the string and the limit
* are all known constants, or null when it cannot be computed.
*/
private function createConstantSplitType(Type $delimiterType, Type $stringType, ?Type $limitType): ?Type
{
$delimiters = [];
foreach ($delimiterType->getConstantStrings() as $delimiterString) {
$delimiterValue = $delimiterString->getValue();
if ($delimiterValue === '') {
// explode() does not split on an empty separator, it errors out
return null;
}

$delimiters[] = $delimiterValue;
}

if (count($delimiters) === 0) {
return null;
}

$strings = $stringType->getConstantStrings();
if (count($strings) === 0) {
return null;
}

if ($limitType === null) {
$limits = [PHP_INT_MAX];
} else {
$limits = [];
foreach ($limitType->getFiniteTypes() as $finiteType) {
if (!$finiteType instanceof ConstantIntegerType) {
return null;
}

$limits[] = $finiteType->getValue();
}

if (count($limits) === 0) {
return null;
}
}

if (count($delimiters) * count($strings) * count($limits) > self::CONSTANT_COMBINATION_LIMIT) {
return null;
}

$results = [];
foreach ($delimiters as $delimiter) {
foreach ($strings as $string) {
foreach ($limits as $limit) {
$items = explode($delimiter, $string->getValue(), $limit);
if (count($items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return null;
}

$builder = ConstantArrayTypeBuilder::createEmpty();
foreach ($items as $i => $item) {
$builder->setOffsetValueType(new ConstantIntegerType($i), new ConstantStringType($item));
}

$results[] = $builder->getArray();
}
}
}

return TypeCombinator::union(...$results);
}

/**
* A limit of 0 or 1 returns the whole string as the only element, without
* splitting on the delimiter.
Expand Down
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/array-destructuring.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@ function (\stdClass $obj) {
assertType('non-empty-string', $secondStringArrayForeachList);
assertType('non-empty-string', $thirdStringArrayForeachList);
assertType('non-empty-string', $fourthStringArrayForeachList);
assertType('lowercase-string&uppercase-string', $dateArray['Y']);
assertType('lowercase-string&uppercase-string', $dateArray['m']);
assertType("'2018'", $dateArray['Y']);
assertType("'12'", $dateArray['m']);
assertType('int', $dateArray['d']);
assertType('lowercase-string&uppercase-string', $intArrayForRewritingFirstElement[0]);
assertType("''", $intArrayForRewritingFirstElement[0]);
assertType('int', $intArrayForRewritingFirstElement[1]);
assertType('ArrayAccess&stdClass', $obj);
assertType('stdClass', $newArray['newKey']);
Expand Down
16 changes: 16 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15013.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types = 1);

namespace Bug15013;

use function PHPStan\Testing\assertType;

function () {
$string = 'App/Service::foo';

assertType("array{'App/Service::foo'}", explode(':::', $string));

[$first, $second] = explode(':::', $string);

assertType("'App/Service::foo'", $first);
assertType('*ERROR*', $second);
};
31 changes: 31 additions & 0 deletions tests/PHPStan/Analyser/nsrt/explode.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,34 @@ function (string $delimiter, $mixed) {
assertType('non-empty-list<string>', $benevolentArrayOrFalse);

};

/**
* @param ','|';' $delimiterUnion
* @param 'a,b'|'x;y;z' $stringUnion
* @param 1|2 $limitUnion
* @param int<1, 3> $limitRange
* @param ''|',' $maybeEmptyDelimiter
*/
function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void
{
assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c'));
assertType("array{'App/Service::foo'}", explode(':::', 'App/Service::foo'));
assertType("array{''}", explode(',', ''));
assertType("array{'a', 'b,c'}", explode(',', 'a,b,c', 2));
assertType("array{'a,b,c'}", explode(',', 'a,b,c', 0));
assertType("array{'a,b,c'}", explode(',', 'a,b,c', 1));
assertType("array{'a', 'b'}", explode(',', 'a,b,c', -1));
assertType('array{}', explode(',', 'a,b', -5));

assertType("array{'a', 'b'}|array{'a,b'}|array{'x', 'y', 'z'}|array{'x;y;z'}", explode($delimiterUnion, $stringUnion));
assertType("array{'a', 'b'}|array{'x;y;z'}", explode(',', $stringUnion));
assertType("array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitUnion));
assertType("array{'a', 'b', 'c'}|array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitRange));

// the delimiter may be an empty string, which is not a valid split
assertType('non-empty-list<lowercase-string>', explode($maybeEmptyDelimiter, 'a,b'));

assertType('non-empty-list<lowercase-string>', explode($unknown, 'a,b'));
assertType('non-empty-list<string>', explode(',', $unknown));
assertType('list<lowercase-string>', explode(',', 'a,b,c', $unknownLimit));
}
10 changes: 10 additions & 0 deletions tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ public function testBug8075(): void
]);
}

public function testBug15013(): void
{
$this->analyse([__DIR__ . '/data/bug-15013.php'], [
[
'Offset 1 does not exist on array{\'App/Service::foo\'}.',
8,
],
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testRuleWithNullsafeVariant(): void
{
Expand Down
15 changes: 15 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-15013.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1);

namespace Bug15013RuleTest;

function () {
$string = 'App/Service::foo';

[$first, $second] = explode(':::', $string);
};

function () {
$string = 'App/Service::foo';

[$first, $second] = explode('::', $string);
};
Loading