1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-30 04:39:00 +01:00

Fix whole bunches of things

This commit is contained in:
Matthew Brown 2018-05-07 01:26:06 -04:00
parent 3b9b4a8a6f
commit 61aeea6375
20 changed files with 861 additions and 696 deletions

View File

@ -9,257 +9,10 @@ use Psalm\FileSource;
use Psalm\Issue\ParadoxicalCondition;
use Psalm\Issue\RedundantCondition;
use Psalm\IssueBuffer;
use Psalm\Type\Algebra;
class AlgebraChecker
{
/** @var array<string, array<int, string>> */
private static $broken_paths = [];
/**
* @param PhpParser\Node\Expr $conditional
* @param string|null $this_class_name
* @param FileSource $source
*
* @return array<int, Clause>
*/
public static function getFormula(
PhpParser\Node\Expr $conditional,
$this_class_name,
FileSource $source
) {
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd ||
$conditional instanceof PhpParser\Node\Expr\BinaryOp\LogicalAnd
) {
$left_assertions = self::getFormula(
$conditional->left,
$this_class_name,
$source
);
$right_assertions = self::getFormula(
$conditional->right,
$this_class_name,
$source
);
return array_merge(
$left_assertions,
$right_assertions
);
}
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr ||
$conditional instanceof PhpParser\Node\Expr\BinaryOp\LogicalOr
) {
// at the moment we only support formulae in CNF
$left_clauses = self::getFormula(
$conditional->left,
$this_class_name,
$source
);
$right_clauses = self::getFormula(
$conditional->right,
$this_class_name,
$source
);
return self::combineOredClauses($left_clauses, $right_clauses);
}
$assertions = AssertionFinder::getAssertions(
$conditional,
$this_class_name,
$source
);
if ($assertions) {
$clauses = [];
foreach ($assertions as $var => $type) {
if ($type === 'isset' || $type === '!empty') {
$key_parts = self::breakUpPathIntoParts($var);
$base_key = array_shift($key_parts);
if ($type === 'isset') {
$clauses[] = new Clause([$base_key => ['^isset']]);
} else {
$clauses[] = new Clause([$base_key => ['^!empty']]);
}
while ($key_parts) {
$divider = array_shift($key_parts);
if ($divider === '[') {
$array_key = array_shift($key_parts);
array_shift($key_parts);
$new_base_key = $base_key . '[' . $array_key . ']';
$base_key = $new_base_key;
} elseif ($divider === '->') {
$property_name = array_shift($key_parts);
$new_base_key = $base_key . '->' . $property_name;
$base_key = $new_base_key;
} else {
throw new \InvalidArgumentException('Unexpected divider ' . $divider);
}
if ($type === 'isset') {
$clauses[] = new Clause([$base_key => ['^isset']]);
} else {
$clauses[] = new Clause([$base_key => ['^!empty']]);
}
}
} else {
$clauses[] = new Clause([$var => [$type]]);
}
}
return $clauses;
}
return [new Clause([], true)];
}
/**
* @param string $path
*
* @return array<int, string>
*/
public static function breakUpPathIntoParts($path)
{
if (isset(self::$broken_paths[$path])) {
return self::$broken_paths[$path];
}
$chars = str_split($path);
$string_char = null;
$escape_char = false;
$parts = [''];
$parts_offset = 0;
for ($i = 0, $char_count = count($chars); $i < $char_count; ++$i) {
$char = $chars[$i];
if ($string_char) {
if ($char === $string_char && !$escape_char) {
$string_char = null;
}
if ($char === '\\') {
$escape_char = !$escape_char;
}
$parts[$parts_offset] .= $char;
continue;
}
switch ($char) {
case '[':
case ']':
$parts_offset++;
$parts[$parts_offset] = $char;
$parts_offset++;
continue;
case '\'':
case '"':
if (!isset($parts[$parts_offset])) {
$parts[$parts_offset] = '';
}
$parts[$parts_offset] .= $char;
$string_char = $char;
continue;
case '-':
if ($i < $char_count - 1 && $chars[$i + 1] === '>') {
++$i;
$parts_offset++;
$parts[$parts_offset] = '->';
$parts_offset++;
continue;
}
// fall through
default:
if (!isset($parts[$parts_offset])) {
$parts[$parts_offset] = '';
}
$parts[$parts_offset] .= $char;
}
}
self::$broken_paths[$path] = $parts;
return $parts;
}
/**
* Negates a set of clauses
* negateClauses([$a || $b]) => !$a && !$b
* negateClauses([$a, $b]) => !$a || !$b
* negateClauses([$a, $b || $c]) =>
* (!$a || !$b) &&
* (!$a || !$c)
* negateClauses([$a, $b || $c, $d || $e || $f]) =>
* (!$a || !$b || !$d) &&
* (!$a || !$b || !$e) &&
* (!$a || !$b || !$f) &&
* (!$a || !$c || !$d) &&
* (!$a || !$c || !$e) &&
* (!$a || !$c || !$f)
*
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
public static function negateFormula(array $clauses)
{
foreach ($clauses as $clause) {
self::calculateNegation($clause);
}
return self::groupImpossibilities($clauses);
}
/**
* @param Clause $clause
*
* @return void
*/
public static function calculateNegation(Clause $clause)
{
if ($clause->impossibilities !== null) {
return;
}
$impossibilities = [];
foreach ($clause->possibilities as $var_id => $possiblity) {
$impossibility = [];
foreach ($possiblity as $type) {
if ($type[0] !== '^' && (!isset($type[1]) || $type[1] !== '^')) {
$impossibility[] = TypeChecker::negateType($type);
}
}
if ($impossibility) {
$impossibilities[$var_id] = $impossibility;
}
}
$clause->impossibilities = $impossibilities;
}
/**
* This looks to see if there are any clauses in one formula that contradict
* clauses in another formula, or clauses that duplicate previous clauses
@ -283,7 +36,7 @@ class AlgebraChecker
PhpParser\Node $stmt,
array $new_assigned_var_ids
) {
$negated_formula2 = self::negateFormula($formula2);
$negated_formula2 = Algebra::negateFormula($formula2);
// remove impossible types
foreach ($negated_formula2 as $clause_a) {
@ -332,7 +85,8 @@ class AlgebraChecker
if ($clause_a_contains_b_possibilities) {
if (IssueBuffer::accepts(
new ParadoxicalCondition(
'Encountered a paradox when evaluating the conditional',
'Encountered a paradox when evaluating the conditionals ('
. $clause_a . ') and (' . $clause_b . ')',
new CodeLocation($statements_checker, $stmt)
),
$statements_checker->getSuppressedIssues()
@ -345,308 +99,4 @@ class AlgebraChecker
}
}
}
/**
* This is a very simple simplification heuristic
* for CNF formulae.
*
* It simplifies formulae:
* ($a) && ($a || $b) => $a
* (!$a) && (!$b) && ($a || $b || $c) => $c
*
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
public static function simplifyCNF(array $clauses)
{
$cloned_clauses = [];
// avoid strict duplicates
foreach ($clauses as $clause) {
$cloned_clauses[$clause->getHash()] = clone $clause;
}
// remove impossible types
foreach ($cloned_clauses as $clause_a) {
if (count($clause_a->possibilities) !== 1 || count(array_values($clause_a->possibilities)[0]) !== 1) {
continue;
}
if (!$clause_a->reconcilable || $clause_a->wedge) {
continue;
}
$clause_var = array_keys($clause_a->possibilities)[0];
$only_type = array_pop(array_values($clause_a->possibilities)[0]);
$negated_clause_type = TypeChecker::negateType($only_type);
foreach ($cloned_clauses as $clause_b) {
if ($clause_a === $clause_b || !$clause_b->reconcilable || $clause_b->wedge) {
continue;
}
if (isset($clause_b->possibilities[$clause_var]) &&
in_array($negated_clause_type, $clause_b->possibilities[$clause_var], true)
) {
$clause_b->possibilities[$clause_var] = array_filter(
$clause_b->possibilities[$clause_var],
/**
* @param string $possible_type
*
* @return bool
*/
function ($possible_type) use ($negated_clause_type) {
return $possible_type !== $negated_clause_type;
}
);
if (count($clause_b->possibilities[$clause_var]) === 0) {
unset($clause_b->possibilities[$clause_var]);
$clause_b->impossibilities = null;
}
}
}
}
$deduped_clauses = [];
// avoid strict duplicates
foreach ($cloned_clauses as $clause) {
$deduped_clauses[$clause->getHash()] = clone $clause;
}
$deduped_clauses = array_filter(
$deduped_clauses,
/**
* @return bool
*/
function (Clause $clause) {
return (bool)count($clause->possibilities);
}
);
$simplified_clauses = [];
foreach ($deduped_clauses as $clause_a) {
$is_redundant = false;
foreach ($deduped_clauses as $clause_b) {
if ($clause_a === $clause_b || !$clause_b->reconcilable || $clause_b->wedge) {
continue;
}
if ($clause_a->contains($clause_b)) {
$is_redundant = true;
break;
}
}
if (!$is_redundant) {
$simplified_clauses[] = $clause_a;
}
}
return $simplified_clauses;
}
/**
* Look for clauses with only one possible value
*
* @param array<int, Clause> $clauses
*
* @return array<string, string>
*/
public static function getTruthsFromFormula(array $clauses)
{
$truths = [];
if (empty($clauses)) {
return [];
}
foreach ($clauses as $clause) {
if (!$clause->reconcilable) {
continue;
}
foreach ($clause->possibilities as $var => $possible_types) {
// if there's only one possible type, return it
if (count($clause->possibilities) === 1 && count($possible_types) === 1) {
if (isset($truths[$var])) {
$truths[$var] .= '&' . array_pop($possible_types);
} else {
$truths[$var] = array_pop($possible_types);
}
} elseif (count($clause->possibilities) === 1) {
$with_brackets = 0;
// if there's only one active clause, return all the non-negation clause members ORed together
$things_that_can_be_said = array_filter(
$possible_types,
/**
* @param string $possible_type
*
* @return bool
*
* @psalm-suppress MixedOperand
*/
function ($possible_type) use (&$with_brackets) {
$with_brackets += (int) (strpos($possible_type, '(') > 0);
return $possible_type[0] !== '!';
}
);
if ($things_that_can_be_said && count($things_that_can_be_said) === count($possible_types)) {
$things_that_can_be_said = array_unique($things_that_can_be_said);
if ($with_brackets > 1) {
$bracket_groups = [];
$removed = 0;
foreach ($things_that_can_be_said as $i => $t) {
if (preg_match('/^\^(int|string|float)\(/', $t, $matches)) {
$options = substr($t, strlen((string) $matches[0]), -1);
if (!isset($bracket_groups[(string) $matches[1]])) {
$bracket_groups[(string) $matches[1]] = $options;
} else {
$bracket_groups[(string) $matches[1]] .= ',' . $options;
}
array_splice($things_that_can_be_said, $i - $removed, 1);
$removed++;
}
}
foreach ($bracket_groups as $type => $options) {
$things_that_can_be_said[] = '^' . $type . '(' . $options . ')';
}
}
$truths[$var] = implode('|', $things_that_can_be_said);
}
}
}
}
return $truths;
}
/**
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
protected static function groupImpossibilities(array $clauses)
{
$clause = array_pop($clauses);
$new_clauses = [];
if (!empty($clauses)) {
$grouped_clauses = self::groupImpossibilities($clauses);
if (count($grouped_clauses) > 800) {
// too many impossibilities
return [];
}
foreach ($grouped_clauses as $grouped_clause) {
if ($clause->impossibilities === null) {
throw new \UnexpectedValueException('$clause->impossibilities should not be null');
}
foreach ($clause->impossibilities as $var => $impossible_types) {
foreach ($impossible_types as $impossible_type) {
$new_clause_possibilities = $grouped_clause->possibilities;
if (isset($grouped_clause->possibilities[$var])
&& !in_array($impossible_type, $new_clause_possibilities[$var])
) {
$new_clause_possibilities[$var][] = $impossible_type;
} else {
$new_clause_possibilities[$var] = [$impossible_type];
}
$new_clause = new Clause($new_clause_possibilities);
//$new_clause->reconcilable = $clause->reconcilable;
$new_clauses[] = $new_clause;
}
}
}
} elseif ($clause && !$clause->wedge) {
if ($clause->impossibilities === null) {
throw new \UnexpectedValueException('$clause->impossibilities should not be null');
}
foreach ($clause->impossibilities as $var => $impossible_types) {
foreach ($impossible_types as $impossible_type) {
$new_clause = new Clause([$var => [$impossible_type]]);
//$new_clause->reconcilable = $clause->reconcilable;
$new_clauses[] = $new_clause;
}
}
}
return $new_clauses;
}
/**
* @param array<int, Clause> $left_clauses
* @param array<int, Clause> $right_clauses
*
* @return array<int, Clause>
*/
public static function combineOredClauses(array $left_clauses, array $right_clauses)
{
$clauses = [];
$all_wedges = true;
foreach ($left_clauses as $left_clause) {
foreach ($right_clauses as $right_clause) {
$possibilities = [];
$can_reconcile = true;
$all_wedges = $all_wedges && $left_clause->wedge && $right_clause->wedge;
if ($left_clause->wedge ||
$right_clause->wedge ||
!$left_clause->reconcilable ||
!$right_clause->reconcilable
) {
$can_reconcile = false;
}
foreach ($left_clause->possibilities as $var => $possible_types) {
if (isset($possibilities[$var])) {
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
} else {
$possibilities[$var] = $possible_types;
}
}
foreach ($right_clause->possibilities as $var => $possible_types) {
if (isset($possibilities[$var])) {
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
} else {
$possibilities[$var] = $possible_types;
}
}
$clauses[] = new Clause($possibilities, false, $can_reconcile);
}
}
if ($all_wedges) {
return [new Clause([], true)];
}
return $clauses;
}
}

View File

@ -73,7 +73,7 @@ class DoChecker
}
}
$while_clauses = AlgebraChecker::getFormula(
$while_clauses = \Psalm\Type\Algebra::getFormula(
$stmt->cond,
$context->self,
$statements_checker
@ -103,7 +103,7 @@ class DoChecker
$while_clauses = [new Clause([], true)];
}
$reconcilable_while_types = AlgebraChecker::getTruthsFromFormula($while_clauses);
$reconcilable_while_types = \Psalm\Type\Algebra::getTruthsFromFormula($while_clauses);
if ($reconcilable_while_types) {
$changed_var_ids = [];

View File

@ -15,6 +15,7 @@ use Psalm\IssueBuffer;
use Psalm\Scope\IfScope;
use Psalm\Scope\LoopScope;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class IfChecker
@ -100,8 +101,6 @@ class IfChecker
$if_context->branch_point = $if_context->branch_point ?: (int) $stmt->getAttribute('startFilePos');
}
$if_context->parent_context = $context;
// we need to clone the current context so our ongoing updates to $context don't mess with elseif/else blocks
$original_context = clone $context;
@ -190,7 +189,7 @@ class IfChecker
}
}
$if_clauses = AlgebraChecker::getFormula(
$if_clauses = Algebra::getFormula(
$stmt->cond,
$context->self,
$statements_checker
@ -229,23 +228,23 @@ class IfChecker
// if we have assignments in the if, we may have duplicate clauses
if ($cond_assigned_var_ids) {
$if_clauses = AlgebraChecker::simplifyCNF($if_clauses);
$if_clauses = Algebra::simplifyCNF($if_clauses);
}
$if_context->clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
$if_context->clauses = Algebra::simplifyCNF(array_merge($context->clauses, $if_clauses));
// define this before we alter local claues after reconciliation
$if_scope->reasonable_clauses = $if_context->clauses;
$if_scope->negated_clauses = AlgebraChecker::simplifyCNF(AlgebraChecker::negateFormula($if_clauses));
$if_scope->negated_clauses = Algebra::negateFormula($if_clauses);
$if_scope->negated_types = AlgebraChecker::getTruthsFromFormula(
AlgebraChecker::simplifyCNF(
$if_scope->negated_types = Algebra::getTruthsFromFormula(
Algebra::simplifyCNF(
array_merge($context->clauses, $if_scope->negated_clauses)
)
);
$reconcilable_if_types = AlgebraChecker::getTruthsFromFormula($if_context->clauses);
$reconcilable_if_types = Algebra::getTruthsFromFormula($if_context->clauses);
// if the if has an || in the conditional, we cannot easily reason about it
if ($reconcilable_if_types) {
@ -528,6 +527,10 @@ class IfChecker
$has_break_statement = $final_actions === [ScopeChecker::ACTION_BREAK];
$has_continue_statement = $final_actions === [ScopeChecker::ACTION_CONTINUE];
if (!$has_ending_statements) {
$if_context->parent_context = $outer_context;
}
$if_scope->final_actions = $final_actions;
$project_checker = $statements_checker->getFileChecker()->project_checker;
@ -664,7 +667,7 @@ class IfChecker
$mic_drop = true;
}
$outer_context->clauses = AlgebraChecker::simplifyCNF(
$outer_context->clauses = Algebra::simplifyCNF(
array_merge($outer_context->clauses, $if_scope->negated_clauses)
);
}
@ -754,9 +757,9 @@ class IfChecker
$entry_clauses = array_merge($original_context->clauses, $if_scope->negated_clauses);
if ($if_scope->negated_types) {
$changed_var_ids = [];
$changed_var_ids = [];
if ($if_scope->negated_types) {
$elseif_vars_reconciled = Reconciler::reconcileKeyedTypes(
$if_scope->negated_types,
$elseif_context->vars_in_scope,
@ -824,7 +827,7 @@ class IfChecker
}
}
$elseif_clauses = AlgebraChecker::getFormula(
$elseif_clauses = Algebra::getFormula(
$elseif->cond,
$statements_checker->getFQCLN(),
$statements_checker
@ -879,15 +882,15 @@ class IfChecker
$new_assigned_var_ids
);
$elseif_context->clauses = AlgebraChecker::simplifyCNF(
$elseif_context->clauses = Algebra::simplifyCNF(
array_merge(
$entry_clauses,
$elseif_clauses
)
);
$reconcilable_elseif_types = AlgebraChecker::getTruthsFromFormula($elseif_context->clauses);
$negated_elseif_types = AlgebraChecker::getTruthsFromFormula(AlgebraChecker::negateFormula($elseif_clauses));
$reconcilable_elseif_types = Algebra::getTruthsFromFormula($elseif_context->clauses);
$negated_elseif_types = Algebra::getTruthsFromFormula(Algebra::negateFormula($elseif_clauses));
$all_negated_vars = array_unique(
array_merge(
@ -907,7 +910,7 @@ class IfChecker
}
}
$changed_var_ids = [];
$changed_var_ids = $changed_var_ids ?: [];
// if the elseif has an || in the conditional, we cannot easily reason about it
if ($reconcilable_elseif_types) {
@ -1051,7 +1054,7 @@ class IfChecker
}
if ($if_scope->reasonable_clauses && $elseif_clauses) {
$if_scope->reasonable_clauses = AlgebraChecker::combineOredClauses(
$if_scope->reasonable_clauses = Algebra::combineOredClauses(
$if_scope->reasonable_clauses,
$elseif_clauses
);
@ -1149,7 +1152,7 @@ class IfChecker
$if_scope->negated_clauses = array_merge(
$if_scope->negated_clauses,
AlgebraChecker::negateFormula($elseif_clauses)
Algebra::negateFormula($elseif_clauses)
);
}
@ -1174,14 +1177,14 @@ class IfChecker
$original_context = clone $else_context;
$else_context->clauses = AlgebraChecker::simplifyCNF(
$else_context->clauses = Algebra::simplifyCNF(
array_merge(
$else_context->clauses,
$if_scope->negated_clauses
)
);
$else_types = AlgebraChecker::getTruthsFromFormula($else_context->clauses);
$else_types = Algebra::getTruthsFromFormula($else_context->clauses);
if ($else_types) {
$changed_var_ids = [];
@ -1320,7 +1323,7 @@ class IfChecker
}
}
} elseif ($if_scope->reasonable_clauses) {
$outer_context->clauses = AlgebraChecker::simplifyCNF(
$outer_context->clauses = Algebra::simplifyCNF(
array_merge(
$if_scope->reasonable_clauses,
$original_context->clauses

View File

@ -2,7 +2,6 @@
namespace Psalm\Checker\Statements\Block;
use PhpParser;
use Psalm\Checker\AlgebraChecker;
use Psalm\Checker\ScopeChecker;
use Psalm\Checker\Statements\ExpressionChecker;
use Psalm\Checker\StatementsChecker;
@ -12,6 +11,7 @@ use Psalm\Context;
use Psalm\IssueBuffer;
use Psalm\Scope\LoopScope;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class LoopChecker
@ -58,7 +58,7 @@ class LoopChecker
foreach ($pre_conditions as $pre_condition) {
$pre_condition_clauses = array_merge(
$pre_condition_clauses,
AlgebraChecker::getFormula(
Algebra::getFormula(
$pre_condition,
$loop_scope->loop_context->self,
$statements_checker
@ -358,8 +358,8 @@ class LoopChecker
if ($pre_conditions && $pre_condition_clauses && !ScopeChecker::doesEverBreak($stmts)) {
// if the loop contains an assertion and there are no break statements, we can negate that assertion
// and apply it to the current context
$negated_pre_condition_types = AlgebraChecker::getTruthsFromFormula(
AlgebraChecker::negateFormula($pre_condition_clauses)
$negated_pre_condition_types = Algebra::getTruthsFromFormula(
Algebra::negateFormula($pre_condition_clauses)
);
if ($negated_pre_condition_types) {
@ -477,11 +477,11 @@ class LoopChecker
$asserted_var_ids = Context::getNewOrUpdatedVarIds($outer_context, $loop_context);
$loop_context->clauses = AlgebraChecker::simplifyCNF(
$loop_context->clauses = Algebra::simplifyCNF(
array_merge($outer_context->clauses, $pre_condition_clauses)
);
$reconcilable_while_types = AlgebraChecker::getTruthsFromFormula($loop_context->clauses);
$reconcilable_while_types = Algebra::getTruthsFromFormula($loop_context->clauses);
// if the while has an or as the main component, we cannot safely reason about it
if ($pre_condition instanceof PhpParser\Node\Expr\BinaryOp &&

View File

@ -12,6 +12,7 @@ use Psalm\Issue\ContinueOutsideLoop;
use Psalm\IssueBuffer;
use Psalm\Scope\LoopScope;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class SwitchChecker
@ -133,7 +134,7 @@ class SwitchChecker
$fake_equality = new PhpParser\Node\Expr\BinaryOp\Equal($switch_condition, $case->cond);
$case_clauses = AlgebraChecker::getFormula(
$case_clauses = Algebra::getFormula(
$fake_equality,
$context->self,
$statements_checker
@ -142,9 +143,9 @@ class SwitchChecker
// this will see whether any of the clauses in set A conflict with the clauses in set B
AlgebraChecker::checkForParadox($context->clauses, $case_clauses, $statements_checker, $stmt->cond, []);
$case_context->clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $case_clauses));
$case_context->clauses = Algebra::simplifyCNF(array_merge($context->clauses, $case_clauses));
$reconcilable_if_types = AlgebraChecker::getTruthsFromFormula($case_context->clauses);
$reconcilable_if_types = Algebra::getTruthsFromFormula($case_context->clauses);
// if the if has an || in the conditional, we cannot easily reason about it
if ($reconcilable_if_types) {

View File

@ -59,11 +59,13 @@ class AssertionFinder
return $if_types;
}
if ($var_name = ExpressionChecker::getArrayVarId(
$var_name = ExpressionChecker::getArrayVarId(
$conditional,
$this_class_name,
$source
)) {
);
if ($var_name) {
$if_types[$var_name] = '!falsy';
return $if_types;
@ -90,7 +92,7 @@ class AssertionFinder
$source
);
return TypeChecker::negateTypes($if_types_to_negate);
return \Psalm\Type\Algebra::negateTypes($if_types_to_negate);
}
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\Identical ||
@ -253,7 +255,7 @@ class AssertionFinder
$notif_types = self::getAssertions($base_conditional, $this_class_name, $source);
if (count($notif_types) === 1) {
$if_types = TypeChecker::negateTypes($notif_types);
$if_types = \Psalm\Type\Algebra::negateTypes($notif_types);
}
}

View File

@ -523,8 +523,8 @@ class AssignmentChecker
} elseif ($stmt instanceof PhpParser\Node\Expr\AssignOp\Div
&& $var_type
&& $expr_type
&& $var_type->hasNumericType()
&& $expr_type->hasNumericType()
&& $var_type->hasDefinitelyNumericType()
&& $expr_type->hasDefinitelyNumericType()
&& $array_var_id
) {
$context->vars_in_scope[$array_var_id] = Type::combineUnionTypes(Type::getFloat(), Type::getInt());

View File

@ -2,7 +2,6 @@
namespace Psalm\Checker\Statements\Expression;
use PhpParser;
use Psalm\Checker\AlgebraChecker;
use Psalm\Checker\FunctionLikeChecker;
use Psalm\Checker\Statements\Expression\Assignment\ArrayAssignmentChecker;
use Psalm\Checker\Statements\ExpressionChecker;
@ -22,6 +21,7 @@ use Psalm\Issue\PossiblyNullOperand;
use Psalm\IssueBuffer;
use Psalm\StatementsSource;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Atomic\ObjectLike;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TFalse;
@ -55,7 +55,7 @@ class BinaryOpChecker
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd ||
$stmt instanceof PhpParser\Node\Expr\BinaryOp\LogicalAnd
) {
$if_clauses = AlgebraChecker::getFormula(
$if_clauses = Algebra::getFormula(
$stmt->left,
$statements_checker->getFQCLN(),
$statements_checker
@ -92,9 +92,9 @@ class BinaryOpChecker
ARRAY_FILTER_USE_KEY
);
$simplified_clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
$simplified_clauses = Algebra::simplifyCNF(array_merge($context->clauses, $if_clauses));
$left_type_assertions = AlgebraChecker::getTruthsFromFormula($simplified_clauses);
$left_type_assertions = Algebra::getTruthsFromFormula($simplified_clauses);
$changed_var_ids = [];
@ -173,20 +173,20 @@ class BinaryOpChecker
$new_referenced_var_ids = array_diff_key($new_referenced_var_ids, $new_assigned_var_ids);
$left_clauses = AlgebraChecker::getFormula(
$left_clauses = Algebra::getFormula(
$stmt->left,
$statements_checker->getFQCLN(),
$statements_checker
);
$rhs_clauses = AlgebraChecker::simplifyCNF(
$rhs_clauses = Algebra::simplifyCNF(
array_merge(
$context->clauses,
AlgebraChecker::negateFormula($left_clauses)
Algebra::negateFormula($left_clauses)
)
);
$negated_type_assertions = AlgebraChecker::getTruthsFromFormula($rhs_clauses);
$negated_type_assertions = Algebra::getTruthsFromFormula($rhs_clauses);
$changed_var_ids = [];
@ -271,19 +271,19 @@ class BinaryOpChecker
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\Coalesce) {
$t_if_context = clone $context;
$if_clauses = AlgebraChecker::getFormula(
$if_clauses = Algebra::getFormula(
$stmt,
$statements_checker->getFQCLN(),
$statements_checker
);
$ternary_clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
$ternary_clauses = Algebra::simplifyCNF(array_merge($context->clauses, $if_clauses));
$negated_clauses = AlgebraChecker::negateFormula($if_clauses);
$negated_clauses = Algebra::negateFormula($if_clauses);
$negated_if_types = AlgebraChecker::getTruthsFromFormula($negated_clauses);
$negated_if_types = Algebra::getTruthsFromFormula($negated_clauses);
$reconcilable_if_types = AlgebraChecker::getTruthsFromFormula($ternary_clauses);
$reconcilable_if_types = Algebra::getTruthsFromFormula($ternary_clauses);
$changed_var_ids = [];
@ -539,7 +539,7 @@ class BinaryOpChecker
&& $left_type
&& $right_type
&& ($left_type->isMixedNotFromIsset() || $right_type->isMixedNotFromIsset())
&& ($left_type->hasNumericType() || $right_type->hasNumericType())
&& ($left_type->hasDefinitelyNumericType() || $right_type->hasDefinitelyNumericType())
) {
$source_checker = $statements_source->getSource();
if ($source_checker instanceof FunctionLikeChecker
@ -659,6 +659,8 @@ class BinaryOpChecker
$candidate_result_type = self::analyzeNonDivOperands(
$statements_source,
$codebase,
$config,
$context,
$left,
$right,
$parent,
@ -735,6 +737,7 @@ class BinaryOpChecker
/**
* @param StatementsSource|null $statements_source
* @param \Psalm\Codebase|null $codebase
* @param Context|null $context
* @param string[] &$invalid_left_messages
* @param string[] &$invalid_right_messages
* @param bool &$has_valid_left_operand
@ -745,6 +748,8 @@ class BinaryOpChecker
public static function analyzeNonDivOperands(
$statements_source,
$codebase,
Config $config,
$context,
PhpParser\Node\Expr $left,
PhpParser\Node\Expr $right,
PhpParser\Node $parent,

View File

@ -2,7 +2,6 @@
namespace Psalm\Checker\Statements\Expression\Call;
use PhpParser;
use Psalm\Checker\AlgebraChecker;
use Psalm\Checker\FunctionChecker;
use Psalm\Checker\FunctionLikeChecker;
use Psalm\Checker\ProjectChecker;
@ -24,6 +23,7 @@ use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Atomic\TString;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class FunctionCallChecker extends \Psalm\Checker\Statements\Expression\CallChecker
@ -349,15 +349,15 @@ class FunctionCallChecker extends \Psalm\Checker\Statements\Expression\CallCheck
$function->parts === ['assert'] &&
isset($stmt->args[0])
) {
$assert_clauses = AlgebraChecker::getFormula(
$assert_clauses = \Psalm\Type\Algebra::getFormula(
$stmt->args[0]->value,
$statements_checker->getFQCLN(),
$statements_checker
);
$simplified_clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $assert_clauses));
$simplified_clauses = Algebra::simplifyCNF(array_merge($context->clauses, $assert_clauses));
$assert_type_assertions = AlgebraChecker::getTruthsFromFormula($simplified_clauses);
$assert_type_assertions = Algebra::getTruthsFromFormula($simplified_clauses);
$changed_vars = [];

View File

@ -2,12 +2,12 @@
namespace Psalm\Checker\Statements\Expression;
use PhpParser;
use Psalm\Checker\AlgebraChecker;
use Psalm\Checker\Statements\ExpressionChecker;
use Psalm\Checker\StatementsChecker;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class TernaryChecker
@ -39,23 +39,23 @@ class TernaryChecker
$t_if_context = clone $context;
$if_clauses = AlgebraChecker::getFormula(
$if_clauses = \Psalm\Type\Algebra::getFormula(
$stmt->cond,
$statements_checker->getFQCLN(),
$statements_checker
);
$ternary_clauses = AlgebraChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
$ternary_clauses = Algebra::simplifyCNF(array_merge($context->clauses, $if_clauses));
$negated_clauses = AlgebraChecker::negateFormula($if_clauses);
$negated_clauses = Algebra::negateFormula($if_clauses);
$negated_if_types = AlgebraChecker::getTruthsFromFormula(
AlgebraChecker::simplifyCNF(
$negated_if_types = Algebra::getTruthsFromFormula(
Algebra::simplifyCNF(
array_merge($context->clauses, $negated_clauses)
)
);
$reconcilable_if_types = AlgebraChecker::getTruthsFromFormula($ternary_clauses);
$reconcilable_if_types = Algebra::getTruthsFromFormula($ternary_clauses);
$changed_var_ids = [];

View File

@ -971,46 +971,6 @@ class TypeChecker
return $result_types;
}
/**
* @param array<string, string> $types
*
* @return array<string, string>
*/
public static function negateTypes(array $types)
{
return array_map(
/**
* @param string $type
*
* @return string
*/
function ($type) {
return self::negateType($type);
},
$types
);
}
/**
* @param string $type
*
* @return string
*/
public static function negateType($type)
{
if ($type === 'mixed') {
return $type;
}
$type_parts = explode('&', (string)$type);
foreach ($type_parts as &$type_part) {
$type_part = $type_part[0] === '!' ? substr($type_part, 1) : '!' . $type_part;
}
return implode('&', $type_parts);
}
/**
* @return bool
*/

View File

@ -95,4 +95,45 @@ class Clause
return md5($possibility_string) .
($this->wedge || !$this->reconcilable ? spl_object_hash($this) : '');
}
public function __toString()
{
return implode(
' || ',
array_map(
/**
* @param string $var_id
* @param string[] $values
*
* @return string
*/
function ($var_id, $values) {
return implode(
' || ' ,
array_map(
/**
* @param string $value
*
* @return string
*/
function ($value) use ($var_id) {
if ($value === 'falsy') {
return '!' . $var_id;
}
if ($value === '!falsy') {
return $var_id;
}
return $var_id . '==' . $value;
},
$values
)
);
},
array_keys($this->possibilities),
array_values($this->possibilities)
)
);
}
}

View File

@ -443,7 +443,7 @@ class Context
$clauses_to_keep = [];
foreach ($clauses as $clause) {
\Psalm\Checker\AlgebraChecker::calculateNegation($clause);
\Psalm\Type\Algebra::calculateNegation($clause);
$quoted_remove_var_id = preg_quote($remove_var_id);
@ -468,7 +468,7 @@ class Context
foreach ($clause->possibilities[$remove_var_id] as $type) {
// empty and !empty are not definitive for arrays and scalar types
if (($type === '!falsy' || $type === 'falsy') &&
($new_type->hasArray() || $new_type->hasNumericType())
($new_type->hasArray() || $new_type->hasPossiblyNumericType())
) {
$type_changed = true;
break;

535
src/Psalm/Type/Algebra.php Normal file
View File

@ -0,0 +1,535 @@
<?php
namespace Psalm\Type;
use PhpParser;
use Psalm\Checker\Statements\Expression\AssertionFinder;
use Psalm\Clause;
use Psalm\CodeLocation;
use Psalm\FileSource;
use Psalm\IssueBuffer;
use Psalm\Type\Algebra;
class Algebra
{
/**
* @param array<string, string> $types
*
* @return array<string, string>
*/
public static function negateTypes(array $types)
{
return array_map(
/**
* @param string $type
*
* @return string
*/
function ($type) {
return self::negateType($type);
},
$types
);
}
/**
* @param string $type
*
* @return string
*/
private static function negateType($type)
{
if ($type === 'mixed') {
return $type;
}
$type_parts = explode('&', (string)$type);
foreach ($type_parts as &$type_part) {
$type_part = $type_part[0] === '!' ? substr($type_part, 1) : '!' . $type_part;
}
return implode('&', $type_parts);
}
/**
* @param PhpParser\Node\Expr $conditional
* @param string|null $this_class_name
* @param FileSource $source
*
* @return array<int, Clause>
*/
public static function getFormula(
PhpParser\Node\Expr $conditional,
$this_class_name,
FileSource $source
) {
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd ||
$conditional instanceof PhpParser\Node\Expr\BinaryOp\LogicalAnd
) {
$left_assertions = self::getFormula(
$conditional->left,
$this_class_name,
$source
);
$right_assertions = self::getFormula(
$conditional->right,
$this_class_name,
$source
);
return array_merge(
$left_assertions,
$right_assertions
);
}
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr ||
$conditional instanceof PhpParser\Node\Expr\BinaryOp\LogicalOr
) {
// at the moment we only support formulae in CNF
$left_clauses = self::getFormula(
$conditional->left,
$this_class_name,
$source
);
$right_clauses = self::getFormula(
$conditional->right,
$this_class_name,
$source
);
return self::combineOredClauses($left_clauses, $right_clauses);
}
$assertions = AssertionFinder::getAssertions(
$conditional,
$this_class_name,
$source
);
if ($assertions) {
$clauses = [];
foreach ($assertions as $var => $type) {
if ($type === 'isset' || $type === '!empty') {
$key_parts = Reconciler::breakUpPathIntoParts($var);
$base_key = array_shift($key_parts);
if ($type === 'isset') {
$clauses[] = new Clause([$base_key => ['^isset']]);
} else {
$clauses[] = new Clause([$base_key => ['^!empty']]);
}
while ($key_parts) {
$divider = array_shift($key_parts);
if ($divider === '[') {
$array_key = array_shift($key_parts);
array_shift($key_parts);
$new_base_key = $base_key . '[' . $array_key . ']';
$base_key = $new_base_key;
} elseif ($divider === '->') {
$property_name = array_shift($key_parts);
$new_base_key = $base_key . '->' . $property_name;
$base_key = $new_base_key;
} else {
throw new \InvalidArgumentException('Unexpected divider ' . $divider);
}
if ($type === 'isset') {
$clauses[] = new Clause([$base_key => ['^isset']]);
} else {
$clauses[] = new Clause([$base_key => ['^!empty']]);
}
}
} else {
$clauses[] = new Clause([$var => [$type]]);
}
}
return $clauses;
}
return [new Clause([], true)];
}
/**
* This is a very simple simplification heuristic
* for CNF formulae.
*
* It simplifies formulae:
* ($a) && ($a || $b) => $a
* (!$a) && (!$b) && ($a || $b || $c) => $c
*
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
public static function simplifyCNF(array $clauses)
{
$cloned_clauses = [];
// avoid strict duplicates
foreach ($clauses as $clause) {
$unique_clause = clone $clause;
foreach ($unique_clause->possibilities as $var_id => $possibilities) {
if (count($possibilities)) {
$unique_clause->possibilities[$var_id] = array_unique($possibilities);
}
}
$cloned_clauses[$clause->getHash()] = $unique_clause;
}
// remove impossible types
foreach ($cloned_clauses as $clause_a) {
if (count($clause_a->possibilities) !== 1 || count(array_values($clause_a->possibilities)[0]) !== 1) {
continue;
}
if (!$clause_a->reconcilable || $clause_a->wedge) {
continue;
}
$clause_var = array_keys($clause_a->possibilities)[0];
$only_type = array_pop(array_values($clause_a->possibilities)[0]);
$negated_clause_type = self::negateType($only_type);
foreach ($cloned_clauses as $clause_b) {
if ($clause_a === $clause_b || !$clause_b->reconcilable || $clause_b->wedge) {
continue;
}
if (isset($clause_b->possibilities[$clause_var]) &&
in_array($negated_clause_type, $clause_b->possibilities[$clause_var], true)
) {
$clause_b->possibilities[$clause_var] = array_filter(
$clause_b->possibilities[$clause_var],
/**
* @param string $possible_type
*
* @return bool
*/
function ($possible_type) use ($negated_clause_type) {
return $possible_type !== $negated_clause_type;
}
);
if (count($clause_b->possibilities[$clause_var]) === 0) {
unset($clause_b->possibilities[$clause_var]);
$clause_b->impossibilities = null;
}
}
}
}
$deduped_clauses = [];
// avoid strict duplicates
foreach ($cloned_clauses as $clause) {
$deduped_clauses[$clause->getHash()] = clone $clause;
}
$deduped_clauses = array_filter(
$deduped_clauses,
/**
* @return bool
*/
function (Clause $clause) {
return (bool)count($clause->possibilities);
}
);
$simplified_clauses = [];
foreach ($deduped_clauses as $clause_a) {
$is_redundant = false;
foreach ($deduped_clauses as $clause_b) {
if ($clause_a === $clause_b || !$clause_b->reconcilable || $clause_b->wedge) {
continue;
}
if ($clause_a->contains($clause_b)) {
$is_redundant = true;
break;
}
}
if (!$is_redundant) {
$simplified_clauses[] = $clause_a;
}
}
return $simplified_clauses;
}
/**
* Look for clauses with only one possible value
*
* @param array<int, Clause> $clauses
*
* @return array<string, string>
*/
public static function getTruthsFromFormula(array $clauses)
{
$truths = [];
if (empty($clauses)) {
return [];
}
foreach ($clauses as $clause) {
if (!$clause->reconcilable) {
continue;
}
foreach ($clause->possibilities as $var => $possible_types) {
// if there's only one possible type, return it
if (count($clause->possibilities) === 1 && count($possible_types) === 1) {
if (isset($truths[$var])) {
$truths[$var] .= '&' . array_pop($possible_types);
} else {
$truths[$var] = array_pop($possible_types);
}
} elseif (count($clause->possibilities) === 1) {
$with_brackets = 0;
// if there's only one active clause, return all the non-negation clause members ORed together
$things_that_can_be_said = array_filter(
$possible_types,
/**
* @param string $possible_type
*
* @return bool
*
* @psalm-suppress MixedOperand
*/
function ($possible_type) use (&$with_brackets) {
$with_brackets += (int) (strpos($possible_type, '(') > 0);
return $possible_type[0] !== '!';
}
);
if ($things_that_can_be_said && count($things_that_can_be_said) === count($possible_types)) {
$things_that_can_be_said = array_unique($things_that_can_be_said);
if ($with_brackets > 1) {
$bracket_groups = [];
$removed = 0;
foreach ($things_that_can_be_said as $i => $t) {
if (preg_match('/^\^(int|string|float)\(/', $t, $matches)) {
$options = substr($t, strlen((string) $matches[0]), -1);
if (!isset($bracket_groups[(string) $matches[1]])) {
$bracket_groups[(string) $matches[1]] = $options;
} else {
$bracket_groups[(string) $matches[1]] .= ',' . $options;
}
array_splice($things_that_can_be_said, $i - $removed, 1);
$removed++;
}
}
foreach ($bracket_groups as $type => $options) {
$things_that_can_be_said[] = '^' . $type . '(' . $options . ')';
}
}
$truths[$var] = implode('|', $things_that_can_be_said);
}
}
}
}
return $truths;
}
/**
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
private static function groupImpossibilities(array $clauses)
{
if (count($clauses) > 5000) {
return [];
}
$clause = array_shift($clauses);
$new_clauses = [];
if ($clauses) {
$grouped_clauses = self::groupImpossibilities($clauses);
if (count($grouped_clauses) > 5000) {
return [];
}
foreach ($grouped_clauses as $grouped_clause) {
if ($clause->impossibilities === null) {
throw new \UnexpectedValueException('$clause->impossibilities should not be null');
}
foreach ($clause->impossibilities as $var => $impossible_types) {
foreach ($impossible_types as $impossible_type) {
$new_clause_possibilities = $grouped_clause->possibilities;
if (isset($grouped_clause->possibilities[$var])) {
$new_clause_possibilities[$var][] = $impossible_type;
} else {
$new_clause_possibilities[$var] = [$impossible_type];
}
$new_clause = new Clause($new_clause_possibilities);
//$new_clause->reconcilable = $clause->reconcilable;
$new_clauses[] = $new_clause;
}
}
}
} elseif ($clause && !$clause->wedge) {
if ($clause->impossibilities === null) {
throw new \UnexpectedValueException('$clause->impossibilities should not be null');
}
foreach ($clause->impossibilities as $var => $impossible_types) {
foreach ($impossible_types as $impossible_type) {
$new_clause = new Clause([$var => [$impossible_type]]);
//$new_clause->reconcilable = $clause->reconcilable;
$new_clauses[] = $new_clause;
}
}
}
return $new_clauses;
}
/**
* @param array<int, Clause> $left_clauses
* @param array<int, Clause> $right_clauses
*
* @return array<int, Clause>
*/
public static function combineOredClauses(array $left_clauses, array $right_clauses)
{
$clauses = [];
$all_wedges = true;
foreach ($left_clauses as $left_clause) {
foreach ($right_clauses as $right_clause) {
$possibilities = [];
$can_reconcile = true;
$all_wedges = $all_wedges && $left_clause->wedge && $right_clause->wedge;
if ($left_clause->wedge ||
$right_clause->wedge ||
!$left_clause->reconcilable ||
!$right_clause->reconcilable
) {
$can_reconcile = false;
}
foreach ($left_clause->possibilities as $var => $possible_types) {
if (isset($possibilities[$var])) {
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
} else {
$possibilities[$var] = $possible_types;
}
}
foreach ($right_clause->possibilities as $var => $possible_types) {
if (isset($possibilities[$var])) {
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
} else {
$possibilities[$var] = $possible_types;
}
}
$clauses[] = new Clause($possibilities, false, $can_reconcile);
}
}
if ($all_wedges) {
return [new Clause([], true)];
}
return $clauses;
}
/**
* Negates a set of clauses
* negateClauses([$a || $b]) => !$a && !$b
* negateClauses([$a, $b]) => !$a || !$b
* negateClauses([$a, $b || $c]) =>
* (!$a || !$b) &&
* (!$a || !$c)
* negateClauses([$a, $b || $c, $d || $e || $f]) =>
* (!$a || !$b || !$d) &&
* (!$a || !$b || !$e) &&
* (!$a || !$b || !$f) &&
* (!$a || !$c || !$d) &&
* (!$a || !$c || !$e) &&
* (!$a || !$c || !$f)
*
* @param array<int, Clause> $clauses
*
* @return array<int, Clause>
*/
public static function negateFormula(array $clauses)
{
foreach ($clauses as $clause) {
self::calculateNegation($clause);
}
$negated = self::simplifyCNF(self::groupImpossibilities($clauses));
return $negated;
}
/**
* @param Clause $clause
*
* @return void
*/
public static function calculateNegation(Clause $clause)
{
if ($clause->impossibilities !== null) {
return;
}
$impossibilities = [];
foreach ($clause->possibilities as $var_id => $possiblity) {
$impossibility = [];
foreach ($possiblity as $type) {
if ($type[0] !== '^' && (!isset($type[1]) || $type[1] !== '^')) {
$impossibility[] = self::negateType($type);
}
}
if ($impossibility) {
$impossibilities[$var_id] = $impossibility;
}
}
$clause->impossibilities = $impossibilities;
}
}

View File

@ -34,6 +34,9 @@ use Psalm\Type\Atomic\TTrue;
class Reconciler
{
/** @var array<string, array<int, string>> */
private static $broken_paths = [];
/**
* Takes two arrays and consolidates them, removing null values from existing types where applicable
*
@ -135,7 +138,7 @@ class Reconciler
$changed_var_ids[] = $key;
if (substr($key, -1) === ']') {
$key_parts = AlgebraChecker::breakUpPathIntoParts($key);
$key_parts = self::breakUpPathIntoParts($key);
self::adjustObjectLikeType(
$key_parts,
$existing_types,
@ -345,11 +348,13 @@ class Reconciler
return Type::getNull();
}
$existing_var_atomic_types = $existing_var_type->getTypes();
if ($new_var_type === '!object' && !$existing_var_type->isMixed()) {
$non_object_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if (!$type->isObjectType()) {
$non_object_types[] = $type;
} else {
@ -384,7 +389,7 @@ class Reconciler
$non_scalar_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if (!($type instanceof Scalar)) {
$non_scalar_types[] = $type;
} else {
@ -419,7 +424,7 @@ class Reconciler
$non_bool_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if (!$type instanceof TBool) {
$non_bool_types[] = $type;
} else {
@ -454,7 +459,7 @@ class Reconciler
$non_numeric_types = [];
$did_remove_type = $existing_var_type->hasString();
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if (!$type->isNumericType()) {
$non_numeric_types[] = $type;
} else {
@ -488,8 +493,7 @@ class Reconciler
if (($new_var_type === '!falsy' || $new_var_type === '!empty')
&& !$existing_var_type->isMixed()
) {
$did_remove_type = $existing_var_type->hasString()
|| $existing_var_type->hasNumericType()
$did_remove_type = $existing_var_type->hasDefinitelyNumericType()
|| $existing_var_type->isEmpty()
|| $existing_var_type->hasType('bool')
|| $existing_var_type->possibly_undefined;
@ -510,6 +514,28 @@ class Reconciler
$existing_var_type->addType(new TTrue);
}
if ($existing_var_type->hasType('string')) {
if ($existing_var_atomic_types['string'] instanceof Type\Atomic\TLiteralString) {
$non_empty_values = [];
foreach ($existing_var_atomic_types['string']->values as $string_value => $_) {
if ($string_value) {
$non_empty_values[$string_value] = true;
} else {
$did_remove_type = true;
}
}
if (!$non_empty_values) {
$existing_var_type->removeType('string');
} else {
$existing_var_type->addType(new Type\Atomic\TLiteralString($non_empty_values));
}
} else {
$did_remove_type = true;
}
}
if ($existing_var_type->hasType('array')) {
$did_remove_type = true;
@ -576,8 +602,6 @@ class Reconciler
$negated_type = substr($new_var_type, 1);
$existing_var_atomic_types = $existing_var_type->getTypes();
if (isset($existing_var_atomic_types['int'])
&& $existing_var_type->from_calculation
&& ($negated_type === 'int' || $negated_type === 'float')
@ -700,14 +724,15 @@ class Reconciler
$is_strict_equality = true;
}
$existing_var_atomic_types = $existing_var_type->getTypes();
if ($new_var_type === 'falsy' || $new_var_type === 'empty') {
if ($existing_var_type->isMixed()) {
return $existing_var_type;
}
$did_remove_type = $existing_var_type->hasString()
|| $existing_var_type->hasScalar()
|| $existing_var_type->hasNumericType();
$did_remove_type = $existing_var_type->hasScalar()
|| $existing_var_type->hasDefinitelyNumericType();
if ($existing_var_type->hasType('bool')) {
$did_remove_type = true;
@ -720,8 +745,31 @@ class Reconciler
$existing_var_type->removeType('true');
}
if ($existing_var_type->hasType('array')
&& $existing_var_type->getTypes()['array']->getId() !== 'array<empty, empty>'
if ($existing_var_type->hasType('string')) {
if ($existing_var_atomic_types['string'] instanceof Type\Atomic\TLiteralString) {
$empty_values = [];
foreach ($existing_var_atomic_types['string']->values as $string_value => $_) {
if (!$string_value) {
$empty_values[$string_value] = true;
} else {
$did_remove_type = true;
}
}
if (!$empty_values) {
$existing_var_type->removeType('string');
} else {
$existing_var_type->addType(new Type\Atomic\TLiteralString($empty_values));
}
} else {
$did_remove_type = true;
$existing_var_type->addType(new Type\Atomic\TLiteralString(['' => true, 0 => true]));
}
}
if (isset($existing_var_atomic_types['array'])
&& $existing_var_atomic_types['array']->getId() !== 'array<empty, empty>'
) {
$did_remove_type = true;
$existing_var_type->addType(new TArray(
@ -732,7 +780,7 @@ class Reconciler
));
}
foreach ($existing_var_type->getTypes() as $type_key => $type) {
foreach ($existing_var_atomic_types as $type_key => $type) {
if ($type instanceof TNamedObject
|| $type instanceof TResource
|| $type instanceof TCallable
@ -770,7 +818,7 @@ class Reconciler
$object_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if ($type->isObjectType()) {
$object_types[] = $type;
} else {
@ -851,7 +899,7 @@ class Reconciler
$scalar_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if ($type instanceof Scalar) {
$scalar_types[] = $type;
} else {
@ -886,7 +934,7 @@ class Reconciler
$bool_types = [];
$did_remove_type = false;
foreach ($existing_var_type->getTypes() as $type) {
foreach ($existing_var_atomic_types as $type) {
if ($type instanceof TBool) {
$bool_types[] = $type;
} else {
@ -917,8 +965,6 @@ class Reconciler
return Type::getMixed();
}
$existing_var_atomic_types = $existing_var_type->getTypes();
if (isset($existing_var_atomic_types['int'])
&& $existing_var_type->from_calculation
&& ($new_var_type === 'int' || $new_var_type === 'float')
@ -1370,6 +1416,83 @@ class Reconciler
}
}
/**
* @param string $path
*
* @return array<int, string>
*/
public static function breakUpPathIntoParts($path)
{
if (isset(self::$broken_paths[$path])) {
return self::$broken_paths[$path];
}
$chars = str_split($path);
$string_char = null;
$escape_char = false;
$parts = [''];
$parts_offset = 0;
for ($i = 0, $char_count = count($chars); $i < $char_count; ++$i) {
$char = $chars[$i];
if ($string_char) {
if ($char === $string_char && !$escape_char) {
$string_char = null;
}
if ($char === '\\') {
$escape_char = !$escape_char;
}
$parts[$parts_offset] .= $char;
continue;
}
switch ($char) {
case '[':
case ']':
$parts_offset++;
$parts[$parts_offset] = $char;
$parts_offset++;
continue;
case '\'':
case '"':
if (!isset($parts[$parts_offset])) {
$parts[$parts_offset] = '';
}
$parts[$parts_offset] .= $char;
$string_char = $char;
continue;
case '-':
if ($i < $char_count - 1 && $chars[$i + 1] === '>') {
++$i;
$parts_offset++;
$parts[$parts_offset] = '->';
$parts_offset++;
continue;
}
// fall through
default:
if (!isset($parts[$parts_offset])) {
$parts[$parts_offset] = '';
}
$parts[$parts_offset] .= $char;
}
}
self::$broken_paths[$path] = $parts;
return $parts;
}
/**
* Gets the type for a given (non-existent key) based on the passed keys
*
@ -1381,7 +1504,7 @@ class Reconciler
*/
private static function getValueForKey(ProjectChecker $project_checker, $key, array &$existing_keys)
{
$key_parts = AlgebraChecker::breakUpPathIntoParts($key);
$key_parts = self::breakUpPathIntoParts($key);
if (count($key_parts) === 1) {
return isset($existing_keys[$key_parts[0]]) ? clone $existing_keys[$key_parts[0]] : null;

View File

@ -407,7 +407,17 @@ class Union
/**
* @return bool
*/
public function hasNumericType()
public function hasDefinitelyNumericType()
{
return isset($this->types['int'])
|| isset($this->types['float'])
|| isset($this->types['numeric-string']);
}
/**
* @return bool
*/
public function hasPossiblyNumericType()
{
return isset($this->types['int'])
|| isset($this->types['float'])

View File

@ -786,7 +786,7 @@ class DependencyFinderVisitor extends PhpParser\NodeVisitorAbstract implements P
continue;
}
$if_clauses = \Psalm\Checker\AlgebraChecker::getFormula(
$if_clauses = \Psalm\Type\Algebra::getFormula(
$function_stmt->cond,
$this->fq_classlike_names
? $this->fq_classlike_names[count($this->fq_classlike_names) - 1]
@ -794,9 +794,9 @@ class DependencyFinderVisitor extends PhpParser\NodeVisitorAbstract implements P
$this->file_scanner
);
$negated_formula = \Psalm\Checker\AlgebraChecker::negateFormula($if_clauses);
$negated_formula = \Psalm\Type\Algebra::negateFormula($if_clauses);
$rules = \Psalm\Checker\AlgebraChecker::getTruthsFromFormula($negated_formula);
$rules = \Psalm\Type\Algebra::getTruthsFromFormula($negated_formula);
foreach ($rules as $var_id => $rule) {
if (strpos($rule, '|') !== false) {

View File

@ -541,7 +541,7 @@ class RedundantConditionTest extends TestCase
}',
'error_message' => 'TypeDoesNotContainType',
],
'SKIPPED-twoVarLogicNotNestedWithElseifNegatedInIf' => [
'twoVarLogicNotNestedWithElseifNegatedInIf' => [
'<?php
function foo(?string $a, ?string $b): ?string {
if ($a) {

View File

@ -126,7 +126,25 @@ class TypeAlgebraTest extends TestCase
return $a;
}',
],
'threeVarLogicNotNestedWithNoRedefinitions' => [
'threeVarLogicNotNestedWithNoRedefinitionsWithClasses' => [
'<?php
function foo(?stdClass $a, ?stdClass $b, ?stdClass $c): stdClass {
if ($a) {
// do nothing
} elseif ($b) {
// do nothing here
} elseif ($c) {
// do nothing here
} else {
return new stdClass;
}
if (!$a && !$b) return $c;
if (!$a) return $b;
return $a;
}',
],
'threeVarLogicNotNestedWithNoRedefinitionsWithStrings' => [
'<?php
function foo(?string $a, ?string $b, ?string $c): string {
if ($a) {
@ -547,8 +565,7 @@ class TypeAlgebraTest extends TestCase
function takesA(A $a): void {}
function foo(?A $a, ?A $b, ?A $c): void {
if (!$a || ($b && $c)
) {
if (!$a || ($b && $c)) {
return;
}
@ -722,6 +739,24 @@ class TypeAlgebraTest extends TestCase
return null;
}',
],
'instanceofNoRedundant' => [
'<?php
function logic(Bar $a, ?Bar $b) : void {
if ((!$a instanceof Foo || !$b instanceof Foo)
&& (!$a instanceof Foo || !$b instanceof Bar)
&& (!$a instanceof Bar || !$b instanceof Foo)
&& (!$a instanceof Bar || !$b instanceof Bar)
) {
} else {
if ($b instanceof Foo) {}
}
}
class Foo {}
class Bar extends Foo {}
class Bat extends Foo {}',
],
];
}
@ -836,7 +871,7 @@ class TypeAlgebraTest extends TestCase
if (!$a) return $b;
return $a;
}',
'error_message' => 'NullableReturnStatement',
'error_message' => 'RedundantCondition',
],
'repeatedIfStatements' => [
'<?php

View File

@ -1,13 +1,13 @@
<?php
namespace Psalm\Tests;
use Psalm\Checker\AlgebraChecker;
use Psalm\Checker\FileChecker;
use Psalm\Checker\StatementsChecker;
use Psalm\Checker\TypeChecker;
use Psalm\Clause;
use Psalm\Context;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
class TypeReconciliationTest extends TestCase
@ -91,7 +91,7 @@ class TypeReconciliationTest extends TestCase
new Clause(['$a' => ['!falsy']]),
];
$negated_formula = AlgebraChecker::negateFormula($formula);
$negated_formula = Algebra::negateFormula($formula);
$this->assertSame(1, count($negated_formula));
$this->assertSame(['$a' => ['falsy']], $negated_formula[0]->possibilities);
@ -100,7 +100,7 @@ class TypeReconciliationTest extends TestCase
new Clause(['$a' => ['!falsy'], '$b' => ['!falsy']]),
];
$negated_formula = AlgebraChecker::negateFormula($formula);
$negated_formula = Algebra::negateFormula($formula);
$this->assertSame(2, count($negated_formula));
$this->assertSame(['$a' => ['falsy']], $negated_formula[0]->possibilities);
@ -111,7 +111,7 @@ class TypeReconciliationTest extends TestCase
new Clause(['$b' => ['!falsy']]),
];
$negated_formula = AlgebraChecker::negateFormula($formula);
$negated_formula = Algebra::negateFormula($formula);
$this->assertSame(1, count($negated_formula));
$this->assertSame(['$a' => ['falsy'], '$b' => ['falsy']], $negated_formula[0]->possibilities);
@ -120,7 +120,7 @@ class TypeReconciliationTest extends TestCase
new Clause(['$a' => ['int', 'string'], '$b' => ['!falsy']]),
];
$negated_formula = AlgebraChecker::negateFormula($formula);
$negated_formula = Algebra::negateFormula($formula);
$this->assertSame(3, count($negated_formula));
$this->assertSame(['$a' => ['!int']], $negated_formula[0]->possibilities);
@ -174,7 +174,7 @@ class TypeReconciliationTest extends TestCase
new Clause(['$a' => ['falsy'], '$b' => ['falsy']]),
];
$simplified_formula = AlgebraChecker::simplifyCNF($formula);
$simplified_formula = Algebra::simplifyCNF($formula);
$this->assertSame(2, count($simplified_formula));
$this->assertSame(['$a' => ['!falsy']], $simplified_formula[0]->possibilities);