Skip to content
Merged
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
88 changes: 85 additions & 3 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
use Serializable;
use Throwable;
use function abs;
use function array_diff_key;
use function array_filter;
use function array_key_exists;
use function array_keys;
Expand Down Expand Up @@ -4421,20 +4422,101 @@
$otherScope->nativeExpressionTypes,
);

$afterExtractCall = $this->afterExtractCall && $otherScope->afterExtractCall;

// Remembered calls whose type differs between the branches may merge into
// exactly what the call resolves to on its own - e.g. count($a) of
// int<3, max> and int<0, 2> merges into int<0, max>. Such an entry adds
// nothing and would shadow what later narrowing of $a implies for the call,
// so it is not written into the merged scope at all.
$callCandidates = self::findMergedCallCandidates($differingExpressionKeys, $mergedExpressionTypes);
$scope = ScopeOps::scopeWith(
$this,
$callCandidates === [] ? $mergedExpressionTypes : array_diff_key($mergedExpressionTypes, $callCandidates),
$callCandidates === [] ? $mergedNativeTypes : array_diff_key($mergedNativeTypes, $callCandidates),
$conditionalExpressions,
[],
[],
[],
$this->inFirstLevelStatement,
$afterExtractCall,
);
if ($callCandidates === []) {
/** @var static */
return $scope;
}

$uninformativeKeys = $scope->findUninformativeMergedCalls($callCandidates, $mergedNativeTypes);
if (count($uninformativeKeys) === count($callCandidates)) {
/** @var static */
return $scope;
}

/** @var static */
return ScopeOps::scopeWith(
$this,
$mergedExpressionTypes,
$mergedNativeTypes,
array_diff_key($mergedExpressionTypes, $uninformativeKeys),
array_diff_key($mergedNativeTypes, $uninformativeKeys),
$conditionalExpressions,
[],
[],
[],
$this->inFirstLevelStatement,
$this->afterExtractCall && $otherScope->afterExtractCall,
$afterExtractCall,
);
}

/**
* Certain merged call expressions whose type differs between the merged branches.
*
* @param array<string, true> $differingExpressionKeys
* @param array<string, ExpressionTypeHolder> $mergedExpressionTypes
* @return array<string, ExpressionTypeHolder>
*/
private static function findMergedCallCandidates(array $differingExpressionKeys, array $mergedExpressionTypes): array
{
$candidates = [];
foreach (array_keys($differingExpressionKeys) as $exprString) {
$holder = $mergedExpressionTypes[$exprString] ?? null;
if ($holder === null || !$holder->getCertainty()->yes()) {

Check warning on line 4481 in src/Analyser/MutatingScope.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $candidates = []; foreach (array_keys($differingExpressionKeys) as $exprString) { $holder = $mergedExpressionTypes[$exprString] ?? null; - if ($holder === null || !$holder->getCertainty()->yes()) { + if ($holder === null || $holder->getCertainty()->no()) { continue; } $expr = $holder->getExpr();

Check warning on line 4481 in src/Analyser/MutatingScope.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $candidates = []; foreach (array_keys($differingExpressionKeys) as $exprString) { $holder = $mergedExpressionTypes[$exprString] ?? null; - if ($holder === null || !$holder->getCertainty()->yes()) { + if ($holder === null || $holder->getCertainty()->no()) { continue; } $expr = $holder->getExpr();
continue;
}
$expr = $holder->getExpr();
if (!$expr instanceof FuncCall && !$expr instanceof MethodCall && !$expr instanceof Expr\StaticCall) {
continue;
}
$candidates[$exprString] = $holder;
}

return $candidates;
}

/**
* Called on the merged scope without the candidates: keys of candidates whose
* merged type (and native type) is exactly what the call resolves to here.
*
* @param array<string, ExpressionTypeHolder> $candidates
* @param array<string, ExpressionTypeHolder> $mergedNativeTypes
* @return array<string, true>
*/
private function findUninformativeMergedCalls(array $candidates, array $mergedNativeTypes): array
{
$uninformativeKeys = [];
foreach ($candidates as $exprString => $holder) {
$expr = $holder->getExpr();
if (!$holder->getType()->equals($this->getType($expr))) {
continue;
}
$nativeHolder = $mergedNativeTypes[$exprString] ?? null;
if ($nativeHolder !== null && !$nativeHolder->getType()->equals($this->getNativeType($expr))) {
continue;
}
$uninformativeKeys[$exprString] = true;
}

return $uninformativeKeys;
}

/**
* Drops the keys of class-constant fetches that resolve to their declared value.
*
Expand Down
131 changes: 131 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13750.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

namespace Bug13750;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/** @param array<mixed> $arr */
public function forgetCount(array $arr): void
{
if (count($arr) > 2) {
assertType('non-empty-array<mixed>', $arr);
assertType('int<3, max>', count($arr));
}
assertType('array<mixed>', $arr);
assertType('int<0, max>', count($arr));
if (count($arr, COUNT_RECURSIVE) > 2) {
assertType('non-empty-array<mixed>', $arr);
assertType('int<1, max>', count($arr));
}
}

}

class Analogous
{

/** @var array<int> */
private array $prop = [];

/** @param array<mixed> $arr */
public function notIdentical(array $arr): void
{
if (count($arr) > 2) {
}
assertType('int<0, max>', count($arr));
if ($arr !== []) {
assertType('int<1, max>', count($arr));
assertType('int<1, max>', sizeof($arr));
} else {
assertType('0', count($arr));
}
}

/** @param array<mixed> $arr */
public function truthy(array $arr): void
{
if (count($arr) > 2) {
}
if ($arr) {
assertType('int<1, max>', count($arr));
}
if (!empty($arr)) {
assertType('int<1, max>', count($arr));
}
}

/** @param array<mixed> $arr */
public function offsetChecks(array $arr): void
{
if (count($arr) > 2) {
}
if (isset($arr[0])) {
assertType('int<1, max>', count($arr));
}
if (array_key_exists('a', $arr)) {
assertType('int<1, max>', count($arr));
}
}

/** @param array<mixed> $arr */
public function sizeofRecursive(array $arr): void
{
if (sizeof($arr) > 2) {
}
assertType('int<0, max>', sizeof($arr));
if (sizeof($arr, COUNT_RECURSIVE) > 2) {
assertType('int<1, max>', sizeof($arr));
assertType('int<1, max>', count($arr));
}
if (count($arr, COUNT_RECURSIVE) === 0) {
assertType('0', count($arr));
}
}

public function property(): void
{
if (count($this->prop) > 2) {
}
assertType('int<0, max>', count($this->prop));
if ($this->prop !== []) {
assertType('int<1, max>', count($this->prop));
}
}

public function strlen(string $s): void
{
if (strlen($s) > 2) {
}
assertType('int<0, max>', strlen($s));
if ($s !== '') {
assertType('int<1, max>', strlen($s));
}
}

/** @param array<mixed> $arr */
public function keepsInformativeMergedCount(array $arr): void
{
if (count($arr) === 0) {
return;
}
if (count($arr) > 5) {
}
assertType('int<1, max>', count($arr));
if (count($arr) > 2) {
assertType('int<3, max>', count($arr));
}
}

/** @param array<mixed> $arr */
public function keepsNarrowerCount(array $arr): void
{
if (count($arr) > 2) {
if ($arr !== []) {
assertType('int<3, max>', count($arr));
}
}
}

}
135 changes: 132 additions & 3 deletions turbo-ext/src/MutatingScope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8411,15 +8411,144 @@ class MutatingScope
return zv::Val();
}

bool afterExtractCall = slotBool(PT_MS_PROP_AFTER_EXTRACT_CALL) && theirAfterExtractCall;

/* merged calls no more precise than the call itself are never written
* into the merged scope */
zv::Val callCandidates = findMergedCallCandidates(Z_ARRVAL_P(differing.raw()), Z_ARRVAL_P(mergedTypes));
if (UNEXPECTED(callCandidates.isUndef())) return zv::Val();
HashTable *candidateTable = Z_ARRVAL_P(callCandidates.raw());
uint32_t candidateCount = zend_hash_num_elements(candidateTable);
if (candidateCount == 0) {
return scopeWithTables(
Z_ARRVAL_P(mergedTypes),
Z_ARRVAL_P(mergedNativeTypes),
Z_ARRVAL_P(conditionalExpressions.raw()),
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
slotBool(PT_MS_PROP_IN_FIRST_LEVEL_STATEMENT),
afterExtractCall);
}

zv::Arr withoutTypes = withoutKeys(Z_ARRVAL_P(mergedTypes), candidateTable);
zv::Arr withoutNativeTypes = withoutKeys(Z_ARRVAL_P(mergedNativeTypes), candidateTable);
zv::Val scope = scopeWithTables(
withoutTypes.table(),
withoutNativeTypes.table(),
Z_ARRVAL_P(conditionalExpressions.raw()),
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
slotBool(PT_MS_PROP_IN_FIRST_LEVEL_STATEMENT),
afterExtractCall);
if (UNEXPECTED(scope.isUndef())) return zv::Val();

zv::Val uninformativeKeys = findUninformativeMergedCalls(scope, candidateTable, Z_ARRVAL_P(mergedNativeTypes));
if (UNEXPECTED(uninformativeKeys.isUndef())) return zv::Val();
if (zend_hash_num_elements(Z_ARRVAL_P(uninformativeKeys.raw())) == candidateCount) return scope;

zv::Arr prunedTypes = withoutKeys(Z_ARRVAL_P(mergedTypes), Z_ARRVAL_P(uninformativeKeys.raw()));
zv::Arr prunedNativeTypes = withoutKeys(Z_ARRVAL_P(mergedNativeTypes), Z_ARRVAL_P(uninformativeKeys.raw()));
return scopeWithTables(
Z_ARRVAL_P(mergedTypes),
Z_ARRVAL_P(mergedNativeTypes),
prunedTypes.table(),
prunedNativeTypes.table(),
Z_ARRVAL_P(conditionalExpressions.raw()),
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
(HashTable *) &zend_empty_array,
slotBool(PT_MS_PROP_IN_FIRST_LEVEL_STATEMENT),
slotBool(PT_MS_PROP_AFTER_EXTRACT_CALL) && theirAfterExtractCall);
afterExtractCall);
}

/* array_diff_key($table, $keys) */
static zv::Arr withoutKeys(HashTable *table, HashTable *keys)
{
zv::Arr result = zv::Arr::copyOfTable(table);
result.separate();
for (auto entry : zv::TableRef(keys)) {
zend_string *key = entry.stringKeyOrNull();
if (key != NULL) {
zend_hash_del(result.table(), key);
} else {
zend_hash_index_del(result.table(), entry.indexKey());
}
}
return result;
}

/* private static (twin findMergedCallCandidates()): certain merged call
* expressions whose type differs between the merged branches */
static zv::Val findMergedCallCandidates(HashTable *differingExpressionKeys, HashTable *mergedExpressionTypes)
{
static const int callClasses[] = { PT_CLASS_FUNC_CALL, PT_CLASS_METHOD_CALL, PT_CLASS_STATIC_CALL };
zv::Arr candidates = zv::Arr::create(0);
for (auto entry : zv::TableRef(differingExpressionKeys)) {
zend_string *key = entry.stringKeyOrNull();
zval *holder = key != NULL ? zend_hash_find(mergedExpressionTypes, key) : zend_hash_index_find(mergedExpressionTypes, entry.indexKey());
if (holder == NULL) continue;
zend_long certainty = holderCertainty(zv::Ref(holder));
if (UNEXPECTED(certainty < 0)) return zv::Val();
if (certainty != PT_TRI_YES) continue;
zv::Val expr = holderExpr(zv::Ref(holder));
if (UNEXPECTED(expr.isUndef())) return zv::Val();
bool isCall = false;
for (int classIdx : callClasses) {
if (UNEXPECTED(!isInstance(expr.ref(), classIdx, isCall))) return zv::Val();
if (isCall) break;
}
if (!isCall) continue;
candidates.separate();
Z_TRY_ADDREF_P(holder);
if (key != NULL) {
zend_hash_add_new(candidates.table(), key, holder);
} else {
zend_hash_index_add_new(candidates.table(), entry.indexKey(), holder);
}
}
return zv::Val(std::move(candidates));
}

/* private (twin findUninformativeMergedCalls(), called on the merged scope
* without the candidates): keys of candidates whose merged type (and
* native type) is exactly what the call resolves to there */
static zv::Val findUninformativeMergedCalls(zv::Val &scope, HashTable *candidates, HashTable *mergedNativeTypes)
{
zend_object *scopeObject = requireObject(scope, "getType");
if (UNEXPECTED(scopeObject == NULL)) return zv::Val();

zv::Arr uninformativeKeys = zv::Arr::create(0);
for (auto entry : zv::TableRef(candidates)) {
zv::Val expr = holderExpr(entry.value());
if (UNEXPECTED(expr.isUndef())) return zv::Val();
zv::Val type = holderType(entry.value());
if (UNEXPECTED(type.isUndef())) return zv::Val();
zv::Val freshType = pt_mutating_scope_get_type(scopeObject, expr.raw());
if (UNEXPECTED(freshType.isUndef())) return zv::Val();
bool equal = pt_call_type_equals(type.raw(), freshType.raw());
if (UNEXPECTED(EG(exception) != NULL)) return zv::Val();
if (!equal) continue;
zend_string *key = entry.stringKeyOrNull();
zval *nativeHolder = key != NULL ? zend_hash_find(mergedNativeTypes, key) : zend_hash_index_find(mergedNativeTypes, entry.indexKey());
if (nativeHolder != NULL) {
zv::Val nativeType = holderType(zv::Ref(nativeHolder));
if (UNEXPECTED(nativeType.isUndef())) return zv::Val();
zv::Val freshNativeType = pt_mutating_scope_get_native_type(scopeObject, expr.raw());
if (UNEXPECTED(freshNativeType.isUndef())) return zv::Val();
bool nativeEqual = pt_call_type_equals(nativeType.raw(), freshNativeType.raw());
if (UNEXPECTED(EG(exception) != NULL)) return zv::Val();
if (!nativeEqual) continue;
}
uninformativeKeys.separate();
zval trueZv;
ZVAL_TRUE(&trueZv);
if (key != NULL) {
zend_hash_add_new(uninformativeKeys.table(), key, &trueZv);
} else {
zend_hash_index_add_new(uninformativeKeys.table(), entry.indexKey(), &trueZv);
}
}
return zv::Val(std::move(uninformativeKeys));
}

/* private (twin 4476) — drops the keys of class-constant fetches that
Expand Down
Loading
Loading