mirror of
https://github.com/danog/psalm.git
synced 2024-11-30 04:39:00 +01:00
Merge pull request #28 from vimeo/clause-for-concern
Add support for type algebra
This commit is contained in:
commit
4ee1a588ad
@ -6,6 +6,7 @@ use Psalm\Checker\ScopeChecker;
|
||||
use Psalm\Checker\Statements\ExpressionChecker;
|
||||
use Psalm\Checker\StatementsChecker;
|
||||
use Psalm\Checker\TypeChecker;
|
||||
use Psalm\Clause;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Context;
|
||||
use Psalm\IfScope;
|
||||
@ -77,34 +78,20 @@ class IfChecker
|
||||
|
||||
$reconcilable_if_types = null;
|
||||
|
||||
if ($stmt->cond instanceof PhpParser\Node\Expr\BinaryOp) {
|
||||
$reconcilable_if_types = TypeChecker::getReconcilableTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
$if_clauses = TypeChecker::getFormula(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$if_scope->negatable_if_types = TypeChecker::getNegatableTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
} else {
|
||||
$reconcilable_if_types = TypeChecker::getTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
$if_context->clauses = TypeChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
|
||||
|
||||
$if_scope->negatable_if_types = $reconcilable_if_types;
|
||||
}
|
||||
$negated_clauses = TypeChecker::negateFormula($if_clauses);
|
||||
|
||||
$if_scope->negated_types = $if_scope->negatable_if_types
|
||||
? TypeChecker::negateTypes($if_scope->negatable_if_types)
|
||||
: [];
|
||||
$if_scope->negated_types = TypeChecker::getTruthsFromFormula($negated_clauses);
|
||||
|
||||
$reconcilable_if_types = TypeChecker::getTruthsFromFormula($if_context->clauses);
|
||||
|
||||
// if the if has an || in the conditional, we cannot easily reason about it
|
||||
if ($reconcilable_if_types) {
|
||||
@ -167,23 +154,29 @@ class IfChecker
|
||||
|
||||
// check the elseifs
|
||||
foreach ($stmt->elseifs as $elseif) {
|
||||
$elseif_context = clone $original_context;
|
||||
|
||||
self::checkElseIfBlock(
|
||||
$statements_checker,
|
||||
$elseif,
|
||||
$if_scope,
|
||||
clone $original_context,
|
||||
$context
|
||||
$elseif_context,
|
||||
$context,
|
||||
$negated_clauses
|
||||
);
|
||||
}
|
||||
|
||||
// check the else
|
||||
if ($stmt->else) {
|
||||
$else_context = clone $original_context;
|
||||
|
||||
self::checkElseBlock(
|
||||
$statements_checker,
|
||||
$stmt->else,
|
||||
$if_scope,
|
||||
clone $original_context,
|
||||
$context
|
||||
$else_context,
|
||||
$context,
|
||||
$negated_clauses
|
||||
);
|
||||
}
|
||||
|
||||
@ -298,12 +291,12 @@ class IfChecker
|
||||
// update the parent context as necessary, but only if we can safely reason about type negation.
|
||||
// We only update vars that changed both at the start of the if block and then again by an assignment
|
||||
// in the if statement.
|
||||
if ($if_scope->negatable_if_types && !$mic_drop) {
|
||||
if ($if_scope->negated_types && !$mic_drop) {
|
||||
$outer_context->update(
|
||||
$old_if_context,
|
||||
$if_context,
|
||||
$has_leaving_statements,
|
||||
array_intersect(array_keys($pre_assignment_else_redefined_vars), array_keys($if_scope->negatable_if_types)),
|
||||
array_intersect(array_keys($pre_assignment_else_redefined_vars), array_keys($if_scope->negated_types)),
|
||||
$if_scope->updated_vars
|
||||
);
|
||||
}
|
||||
@ -336,6 +329,7 @@ class IfChecker
|
||||
* @param IfScope $if_scope
|
||||
* @param Context $elseif_context
|
||||
* @param Context $outer_context
|
||||
* @param array<Clause> $negated_clauses
|
||||
* @return false|null
|
||||
*/
|
||||
protected static function checkElseIfBlock(
|
||||
@ -343,7 +337,8 @@ class IfChecker
|
||||
PhpParser\Node\Stmt\ElseIf_ $elseif,
|
||||
IfScope $if_scope,
|
||||
Context $elseif_context,
|
||||
Context $outer_context
|
||||
Context $outer_context,
|
||||
array &$negated_clauses
|
||||
) {
|
||||
$original_context = clone $elseif_context;
|
||||
|
||||
@ -362,37 +357,28 @@ class IfChecker
|
||||
$elseif_context->vars_in_scope = $elseif_vars_reconciled;
|
||||
}
|
||||
|
||||
if ($elseif->cond instanceof PhpParser\Node\Expr\BinaryOp) {
|
||||
$reconcilable_elseif_types = TypeChecker::getReconcilableTypeAssertions(
|
||||
$elseif->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$negatable_elseif_types = TypeChecker::getNegatableTypeAssertions(
|
||||
$elseif->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
} else {
|
||||
$reconcilable_elseif_types = $negatable_elseif_types = TypeChecker::getTypeAssertions(
|
||||
$elseif->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
}
|
||||
|
||||
// check the elseif
|
||||
if (ExpressionChecker::check($statements_checker, $elseif->cond, $elseif_context) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$negated_elseif_types = $negatable_elseif_types
|
||||
? TypeChecker::negateTypes($negatable_elseif_types)
|
||||
: [];
|
||||
$elseif_clauses = TypeChecker::getFormula(
|
||||
$elseif->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$elseif_context->clauses = TypeChecker::simplifyCNF(
|
||||
array_merge(
|
||||
$original_context->clauses,
|
||||
$negated_clauses,
|
||||
$elseif_clauses
|
||||
)
|
||||
);
|
||||
|
||||
$reconcilable_elseif_types = TypeChecker::getTruthsFromFormula($elseif_context->clauses);
|
||||
$negated_elseif_types = TypeChecker::getTruthsFromFormula(TypeChecker::negateFormula($elseif_clauses));
|
||||
|
||||
$all_negated_vars = array_unique(
|
||||
array_merge(
|
||||
@ -489,7 +475,7 @@ class IfChecker
|
||||
}
|
||||
}
|
||||
|
||||
if ($negatable_elseif_types) {
|
||||
if ($negated_elseif_types) {
|
||||
$outer_context->update(
|
||||
$old_elseif_context,
|
||||
$elseif_context,
|
||||
@ -542,6 +528,11 @@ class IfChecker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$negated_clauses = array_merge(
|
||||
$negated_clauses,
|
||||
TypeChecker::negateFormula($elseif_clauses)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -550,6 +541,7 @@ class IfChecker
|
||||
* @param IfScope $if_scope
|
||||
* @param Context $else_context
|
||||
* @param Context $outer_context
|
||||
* @param array<Clause> $negated_clauses
|
||||
* @return false|null
|
||||
*/
|
||||
protected static function checkElseBlock(
|
||||
@ -557,13 +549,23 @@ class IfChecker
|
||||
PhpParser\Node\Stmt\Else_ $else,
|
||||
IfScope $if_scope,
|
||||
Context $else_context,
|
||||
Context $outer_context
|
||||
Context $outer_context,
|
||||
array $negated_clauses
|
||||
) {
|
||||
$original_context = clone $else_context;
|
||||
|
||||
if ($if_scope->negated_types) {
|
||||
$else_context->clauses = TypeChecker::simplifyCNF(
|
||||
array_merge(
|
||||
$outer_context->clauses,
|
||||
$negated_clauses
|
||||
)
|
||||
);
|
||||
|
||||
$else_types = TypeChecker::getTruthsFromFormula($else_context->clauses);
|
||||
|
||||
if ($else_types) {
|
||||
$else_vars_reconciled = TypeChecker::reconcileKeyedTypes(
|
||||
$if_scope->negated_types,
|
||||
$else_types,
|
||||
$else_context->vars_in_scope,
|
||||
new CodeLocation($statements_checker->getSource(), $else),
|
||||
$statements_checker->getSuppressedIssues()
|
||||
|
@ -156,16 +156,20 @@ class SwitchChecker
|
||||
}
|
||||
}
|
||||
|
||||
$context_new_vars = array_diff_key($case_context->vars_in_scope, $context->vars_in_scope);
|
||||
|
||||
if ($new_vars_in_scope === null) {
|
||||
$new_vars_in_scope = array_diff_key($case_context->vars_in_scope, $context->vars_in_scope);
|
||||
$new_vars_in_scope = $context_new_vars;
|
||||
$new_vars_possibly_in_scope = array_diff_key(
|
||||
$case_context->vars_possibly_in_scope,
|
||||
$context->vars_possibly_in_scope
|
||||
);
|
||||
} else {
|
||||
foreach ($new_vars_in_scope as $new_var => $type) {
|
||||
foreach ($new_vars_in_scope as $new_var => &$type) {
|
||||
if (!isset($case_context->vars_in_scope[$new_var])) {
|
||||
unset($new_vars_in_scope[$new_var]);
|
||||
} else {
|
||||
$type = Type::combineUnionTypes($case_context->vars_in_scope[$new_var], $type);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -860,7 +860,7 @@ class FetchChecker
|
||||
if (!$keyed_assignment_type) {
|
||||
throw new \UnexpectedValueException('$keyed_assignment_type cannot be null');
|
||||
}
|
||||
|
||||
|
||||
$assignment_type = new Type\Union([
|
||||
new Type\Generic(
|
||||
'array',
|
||||
|
@ -661,13 +661,17 @@ class ExpressionChecker
|
||||
if ($stmt instanceof PhpParser\Node\Expr\BinaryOp\Concat && $nesting > 20) {
|
||||
// ignore deeply-nested string concatenation
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
$left_type_assertions = TypeChecker::getReconcilableTypeAssertions(
|
||||
$if_clauses = TypeChecker::getFormula(
|
||||
$stmt->left,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$simplified_clauses = TypeChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
|
||||
|
||||
$left_type_assertions = TypeChecker::getTruthsFromFormula($simplified_clauses);
|
||||
|
||||
if (self::check($statements_checker, $stmt->left, $context) === false) {
|
||||
return false;
|
||||
}
|
||||
@ -706,14 +710,21 @@ class ExpressionChecker
|
||||
$context->vars_possibly_in_scope
|
||||
);
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr) {
|
||||
$left_type_assertions = TypeChecker::getNegatableTypeAssertions(
|
||||
$if_clauses = TypeChecker::getFormula(
|
||||
$stmt->left,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$negated_type_assertions = TypeChecker::negateTypes($left_type_assertions);
|
||||
$rhs_clauses = TypeChecker::simplifyCNF(
|
||||
array_merge(
|
||||
$context->clauses,
|
||||
TypeChecker::negateFormula($if_clauses)
|
||||
)
|
||||
);
|
||||
|
||||
$negated_type_assertions = TypeChecker::getTruthsFromFormula($rhs_clauses);
|
||||
|
||||
if (self::check($statements_checker, $stmt->left, $context) === false) {
|
||||
return false;
|
||||
@ -733,6 +744,7 @@ class ExpressionChecker
|
||||
}
|
||||
|
||||
$op_context = clone $context;
|
||||
$op_context->clauses = $rhs_clauses;
|
||||
$op_context->vars_in_scope = $op_vars_in_scope;
|
||||
|
||||
if (self::check($statements_checker, $stmt->right, $op_context) === false) {
|
||||
@ -937,7 +949,7 @@ class ExpressionChecker
|
||||
} else {
|
||||
$result_type = Type::combineUnionTypes(Type::getFloat(), $result_type);
|
||||
}
|
||||
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -961,7 +973,7 @@ class ExpressionChecker
|
||||
} else {
|
||||
$result_type = Type::combineUnionTypes(Type::getFloat(), $result_type);
|
||||
}
|
||||
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1057,7 +1069,7 @@ class ExpressionChecker
|
||||
$result_type = Type::getString();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if ($config->strict_binary_operands) {
|
||||
if (IssueBuffer::accepts(
|
||||
new InvalidOperand(
|
||||
@ -1380,28 +1392,20 @@ class ExpressionChecker
|
||||
|
||||
$t_if_context = clone $context;
|
||||
|
||||
if ($stmt->cond instanceof PhpParser\Node\Expr\BinaryOp) {
|
||||
$reconcilable_if_types = TypeChecker::getReconcilableTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
$if_clauses = TypeChecker::getFormula(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
|
||||
$negatable_if_types = TypeChecker::getNegatableTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
} else {
|
||||
$reconcilable_if_types = $negatable_if_types = TypeChecker::getTypeAssertions(
|
||||
$stmt->cond,
|
||||
$statements_checker->getFQCLN(),
|
||||
$statements_checker->getNamespace(),
|
||||
$statements_checker->getAliasedClasses()
|
||||
);
|
||||
}
|
||||
$ternary_clauses = TypeChecker::simplifyCNF(array_merge($context->clauses, $if_clauses));
|
||||
|
||||
$negated_clauses = TypeChecker::negateFormula($if_clauses);
|
||||
|
||||
$negated_if_types = TypeChecker::getTruthsFromFormula($negated_clauses);
|
||||
|
||||
$reconcilable_if_types = TypeChecker::getTruthsFromFormula($ternary_clauses);
|
||||
|
||||
$if_return_type = null;
|
||||
|
||||
@ -1426,9 +1430,7 @@ class ExpressionChecker
|
||||
|
||||
$t_else_context = clone $context;
|
||||
|
||||
if ($negatable_if_types) {
|
||||
$negated_if_types = TypeChecker::negateTypes($negatable_if_types);
|
||||
|
||||
if ($negated_if_types) {
|
||||
$t_else_vars_in_scope_reconciled = TypeChecker::reconcileKeyedTypes(
|
||||
$negated_if_types,
|
||||
$t_else_context->vars_in_scope,
|
||||
|
@ -203,8 +203,8 @@ class StatementsChecker
|
||||
}
|
||||
|
||||
/*
|
||||
if (isset($context->vars_in_scope['$value_types'])) {
|
||||
var_dump($stmt->getLine() . ' ' . $context->vars_in_scope['$value_types']);
|
||||
if (isset($context->vars_in_scope['$type'])) {
|
||||
var_dump($stmt->getLine() . ' ' . $context->vars_in_scope['$type']);
|
||||
}
|
||||
*/
|
||||
|
||||
|
@ -3,6 +3,7 @@ namespace Psalm\Checker;
|
||||
|
||||
use PhpParser;
|
||||
use Psalm\Checker\Statements\ExpressionChecker;
|
||||
use Psalm\Clause;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Issue\FailedTypeResolution;
|
||||
use Psalm\Issue\TypeDoesNotContainType;
|
||||
@ -15,105 +16,370 @@ class TypeChecker
|
||||
const ASSIGNMENT_TO_LEFT = -1;
|
||||
|
||||
/**
|
||||
* Gets all the type assertions in a conditional that are && together
|
||||
*
|
||||
* @param PhpParser\Node\Expr $conditional
|
||||
* @param string $this_class_name
|
||||
* @param string $namespace
|
||||
* @param array<string, string> $aliased_classes
|
||||
* @return array<string,string>
|
||||
* @return array<int, Clause>
|
||||
*/
|
||||
public static function getReconcilableTypeAssertions(
|
||||
public static function getFormula(
|
||||
PhpParser\Node\Expr $conditional,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
array $aliased_classes
|
||||
) {
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr) {
|
||||
$left_assertions = self::getReconcilableTypeAssertions(
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
$left_assertions = self::getFormula(
|
||||
$conditional->left,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
|
||||
$right_assertions = self::getReconcilableTypeAssertions(
|
||||
$right_assertions = self::getFormula(
|
||||
$conditional->right,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
|
||||
$keys = array_intersect(array_keys($left_assertions), array_keys($right_assertions));
|
||||
return array_merge(
|
||||
$left_assertions,
|
||||
$right_assertions
|
||||
);
|
||||
}
|
||||
|
||||
$if_types = [];
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr) {
|
||||
// at the moment we only support formulae in CNF
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if ($left_assertions[$key][0] !== '!' && $right_assertions[$key][0] !== '!') {
|
||||
$if_types[$key] = $left_assertions[$key] . '|' . $right_assertions[$key];
|
||||
if (!$conditional->left instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
$left_clauses = self::getFormula(
|
||||
$conditional->left,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
} else {
|
||||
$left_clauses = [new Clause([], true)];
|
||||
}
|
||||
|
||||
if (!$conditional->right instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
$right_clauses = self::getFormula(
|
||||
$conditional->right,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
} else {
|
||||
$right_clauses = [new Clause([], true)];
|
||||
}
|
||||
|
||||
/** @var array<string, array<string>> */
|
||||
$possibilities = [];
|
||||
|
||||
if ($left_clauses[0]->wedge && $right_clauses[0]->wedge) {
|
||||
return [new Clause([], true)];
|
||||
}
|
||||
|
||||
$can_reconcile = true;
|
||||
|
||||
if ($left_clauses[0]->wedge ||
|
||||
$right_clauses[0]->wedge ||
|
||||
!$left_clauses[0]->reconcilable ||
|
||||
!$right_clauses[0]->reconcilable
|
||||
) {
|
||||
$can_reconcile = false;
|
||||
}
|
||||
|
||||
foreach ($left_clauses[0]->possibilities as $var => $possible_types) {
|
||||
if (isset($possibilities[$var])) {
|
||||
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
|
||||
} else {
|
||||
$possibilities[$var] = $possible_types;
|
||||
}
|
||||
}
|
||||
|
||||
return $if_types;
|
||||
foreach ($right_clauses[0]->possibilities as $var => $possible_types) {
|
||||
if (isset($possibilities[$var])) {
|
||||
$possibilities[$var] = array_merge($possibilities[$var], $possible_types);
|
||||
} else {
|
||||
$possibilities[$var] = $possible_types;
|
||||
}
|
||||
}
|
||||
|
||||
return [new Clause($possibilities, false, $can_reconcile)];
|
||||
}
|
||||
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
$left_assertions = self::getReconcilableTypeAssertions(
|
||||
$conditional->left,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
$assertions = self::getTypeAssertions(
|
||||
$conditional,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
|
||||
$right_assertions = self::getReconcilableTypeAssertions(
|
||||
$conditional->right,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
if ($assertions) {
|
||||
$possibilities = [];
|
||||
|
||||
return self::combineTypeAssertions($left_assertions, $right_assertions);
|
||||
foreach ($assertions as $var => $type) {
|
||||
$possibilities[$var] = [$type];
|
||||
}
|
||||
|
||||
return [new Clause($possibilities)];
|
||||
}
|
||||
|
||||
return self::getTypeAssertions($conditional, $this_class_name, $namespace, $aliased_classes, true);
|
||||
return [new Clause([], true)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PhpParser\Node\Expr $conditional
|
||||
* @param string $this_class_name
|
||||
* @param string $namespace
|
||||
* @param array<string, string> $aliased_classes
|
||||
* @return array<string,string>
|
||||
* 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<Clause> $clauses
|
||||
* @return array<Clause>
|
||||
*/
|
||||
public static function getNegatableTypeAssertions(
|
||||
PhpParser\Node\Expr $conditional,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
array $aliased_classes
|
||||
) {
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanAnd) {
|
||||
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;
|
||||
}
|
||||
|
||||
$clause->impossibilities = array_map(
|
||||
/**
|
||||
* @param array<string> $types
|
||||
* @return array<string>
|
||||
*/
|
||||
function (array $types) {
|
||||
return array_map(
|
||||
/**
|
||||
* @param string $type
|
||||
* @return string
|
||||
*/
|
||||
function ($type) {
|
||||
return self::negateType($type);
|
||||
},
|
||||
$types
|
||||
);
|
||||
},
|
||||
$clause->possibilities
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Clause> $clauses
|
||||
* @return array<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 = 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])
|
||||
) {
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cloned_clauses = array_filter(
|
||||
$cloned_clauses,
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
function (Clause $clause) {
|
||||
return (bool)count($clause->possibilities);
|
||||
}
|
||||
);
|
||||
|
||||
$simplified_clauses = [];
|
||||
|
||||
foreach ($cloned_clauses as $clause_a) {
|
||||
$is_redundant = false;
|
||||
|
||||
foreach ($cloned_clauses as $clause_b) {
|
||||
if ($clause_a === $clause_b) {
|
||||
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<Clause> $clauses
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getTruthsFromFormula(array $clauses)
|
||||
{
|
||||
$truths = [];
|
||||
|
||||
if (empty($clauses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($conditional instanceof PhpParser\Node\Expr\BinaryOp\BooleanOr) {
|
||||
$left_assertions = self::getNegatableTypeAssertions(
|
||||
$conditional->left,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
foreach ($clauses as $clause) {
|
||||
if (!$clause->reconcilable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$right_assertions = self::getNegatableTypeAssertions(
|
||||
$conditional->right,
|
||||
$this_class_name,
|
||||
$namespace,
|
||||
$aliased_classes
|
||||
);
|
||||
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) {
|
||||
// if there's only one active clause, return all the non-negation clause members ORed together
|
||||
$things_that_can_be_said = implode(
|
||||
'|',
|
||||
array_filter(
|
||||
$possible_types,
|
||||
/**
|
||||
* @param string $possible_type
|
||||
* @return bool
|
||||
*/
|
||||
function ($possible_type) {
|
||||
return $possible_type[0] !== '!';
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return self::combineTypeAssertions($left_assertions, $right_assertions);
|
||||
if ($things_that_can_be_said) {
|
||||
$truths[$var] = $things_that_can_be_said;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::getTypeAssertions($conditional, $this_class_name, $namespace, $aliased_classes);
|
||||
return $truths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Clause> $clauses
|
||||
* @return array<Clause>
|
||||
*/
|
||||
protected static function groupImpossibilities(array $clauses)
|
||||
{
|
||||
$clause = array_pop($clauses);
|
||||
|
||||
$new_clauses = [];
|
||||
|
||||
if (count($clauses)) {
|
||||
$grouped_clauses = self::groupImpossibilities($clauses);
|
||||
|
||||
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_clauses[] = new Clause($new_clause_possibilities);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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_clauses[] = new Clause([$var => [$impossible_type]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $new_clauses;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1683,22 +1949,31 @@ class TypeChecker
|
||||
* @return string
|
||||
*/
|
||||
function ($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 self::negateType($type);
|
||||
},
|
||||
$types
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @return string
|
||||
*/
|
||||
protected 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 Type\Union $declared_type
|
||||
* @param Type\Union $inferred_type
|
||||
|
88
src/Psalm/Clause.php
Normal file
88
src/Psalm/Clause.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace Psalm;
|
||||
|
||||
class Clause
|
||||
{
|
||||
/**
|
||||
* An array of strings of the form
|
||||
* [
|
||||
* '$a' => ['empty'],
|
||||
* '$b' => ['!empty'],
|
||||
* '$c' => ['!null'],
|
||||
* '$d' => ['string', 'int']
|
||||
* ]
|
||||
*
|
||||
* representing the formula
|
||||
*
|
||||
* !$a || $b || $c !== null || is_string($d) || is_int($d)
|
||||
*
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
public $possibilities;
|
||||
|
||||
/**
|
||||
* An array of things that are not true
|
||||
* [
|
||||
* '$a' => ['!empty'],
|
||||
* '$b' => ['empty'],
|
||||
* '$c' => ['null'],
|
||||
* '$d' => ['!string', '!int']
|
||||
* ]
|
||||
* represents the formula
|
||||
*
|
||||
* $a && !$b && $c === null && !is_string($d) && !is_int($d)
|
||||
*
|
||||
* @var array<string, array<string>>|null
|
||||
*/
|
||||
public $impossibilities;
|
||||
|
||||
/** @var bool */
|
||||
public $wedge;
|
||||
|
||||
/** @var bool */
|
||||
public $reconcilable;
|
||||
|
||||
/**
|
||||
* @param array<string, array<string>> $possibilities
|
||||
* @param bool $wedge
|
||||
* @param bool $reconcilable
|
||||
*/
|
||||
public function __construct(array $possibilities, $wedge = false, $reconcilable = true)
|
||||
{
|
||||
$this->possibilities = $possibilities;
|
||||
$this->wedge = $wedge;
|
||||
$this->reconcilable = $reconcilable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Clause $other_clause
|
||||
* @return bool
|
||||
*/
|
||||
public function contains(Clause $other_clause)
|
||||
{
|
||||
foreach ($other_clause->possibilities as $var => $possible_types) {
|
||||
if (!isset($this->possibilities[$var]) || count(array_diff($possible_types, $this->possibilities[$var]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a hash of the object – will be unique if we're unable to easily reconcile this with others
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHash()
|
||||
{
|
||||
ksort($this->possibilities);
|
||||
|
||||
foreach ($this->possibilities as $var => &$possible_types) {
|
||||
sort($possible_types);
|
||||
}
|
||||
|
||||
return md5(json_encode($this->possibilities)) .
|
||||
($this->wedge || !$this->reconcilable ? spl_object_hash($this) : '');
|
||||
}
|
||||
}
|
@ -65,6 +65,13 @@ class Context
|
||||
*/
|
||||
protected $phantom_classes = [];
|
||||
|
||||
/**
|
||||
* A list of clauses in Conjunctive Normal Form
|
||||
*
|
||||
* @var array<Clause>
|
||||
*/
|
||||
public $clauses = [];
|
||||
|
||||
/**
|
||||
* @param string $file_name
|
||||
* @param string|null $self
|
||||
@ -85,6 +92,10 @@ class Context
|
||||
$type = clone $type;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->clauses as &$clause) {
|
||||
$clause = clone $clause;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -176,6 +187,16 @@ class Context
|
||||
return;
|
||||
}
|
||||
|
||||
$clauses_to_keep = [];
|
||||
|
||||
foreach ($this->clauses as $clause) {
|
||||
if (!isset($clause->possibilities[$remove_var_id])) {
|
||||
$clauses_to_keep[] = $clause;
|
||||
}
|
||||
}
|
||||
|
||||
$this->clauses = $clauses_to_keep;
|
||||
|
||||
if ($type->hasArray() || $type->isMixed()) {
|
||||
$vars_to_remove = [];
|
||||
|
||||
|
@ -42,14 +42,6 @@ class EffectsAnalyser
|
||||
}
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\Yield_ || $stmt instanceof PhpParser\Node\Expr\YieldFrom) {
|
||||
$yield_types = array_merge($yield_types, self::getYieldTypeFromExpression($stmt));
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\YieldFrom) {
|
||||
$key_type = null;
|
||||
|
||||
if (isset($stmt->inferredType)) {
|
||||
$return_types = array_merge(array_values($stmt->inferredType->types), $return_types);
|
||||
} else {
|
||||
$return_types[] = new Type\Atomic('mixed');
|
||||
}
|
||||
} elseif ($stmt instanceof PhpParser\Node\Stmt\If_) {
|
||||
$return_types = array_merge($return_types, self::getReturnTypes($stmt->stmts, $yield_types));
|
||||
|
||||
|
@ -798,4 +798,117 @@ class ScopeTest extends PHPUnit_Framework_TestCase
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$file_checker->check();
|
||||
}
|
||||
|
||||
public function testTwoVarLogic()
|
||||
{
|
||||
$stmts = self::$parser->parse('<?php
|
||||
$a = rand(0, 10) ? "hello" : null;
|
||||
$b = rand(0, 10) ? "goodbye" : null;
|
||||
|
||||
if ($a || $b) {
|
||||
if ($a) {
|
||||
$c = $a;
|
||||
} else {
|
||||
$c = $b;
|
||||
}
|
||||
|
||||
echo strpos($c, "e");
|
||||
}
|
||||
');
|
||||
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$file_checker->check();
|
||||
}
|
||||
|
||||
public function testThreeVarLogic()
|
||||
{
|
||||
$stmts = self::$parser->parse('<?php
|
||||
$a = rand(0, 10) ? "hello" : null;
|
||||
$b = rand(0, 10) ? "goodbye" : null;
|
||||
$c = rand(0, 10) ? "hello" : null;
|
||||
|
||||
if ($a || $b || $c) {
|
||||
if ($a) {
|
||||
$d = $a;
|
||||
} elseif ($b) {
|
||||
$d = $b;
|
||||
} else {
|
||||
$d = $c;
|
||||
}
|
||||
|
||||
echo strpos($d, "e");
|
||||
}
|
||||
');
|
||||
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$file_checker->check();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Psalm\Exception\CodeException
|
||||
* @expectedExceptionMessage InvalidScalarArgument
|
||||
*/
|
||||
public function testThreeVarLogicWithChange()
|
||||
{
|
||||
$stmts = self::$parser->parse('<?php
|
||||
$a = rand(0, 10) ? "hello" : null;
|
||||
$b = rand(0, 10) ? "goodbye" : null;
|
||||
$c = rand(0, 10) ? "hello" : null;
|
||||
|
||||
if ($a || $b || $c) {
|
||||
$c = false;
|
||||
|
||||
if ($a) {
|
||||
$d = $a;
|
||||
} elseif ($b) {
|
||||
$d = $b;
|
||||
} else {
|
||||
$d = $c;
|
||||
}
|
||||
|
||||
echo strpos($d, "e");
|
||||
}
|
||||
');
|
||||
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$file_checker->check();
|
||||
}
|
||||
|
||||
public function testNegateAssertionAndOther()
|
||||
{
|
||||
$stmts = self::$parser->parse('<?php
|
||||
$a = rand(0, 10) ? "hello" : null;
|
||||
|
||||
if (rand(0, 10) > 1 && is_string($a)) {
|
||||
throw new \Exception("bad");
|
||||
}
|
||||
');
|
||||
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$context = new Context('somefile.php');
|
||||
$file_checker->check(true, true, $context);
|
||||
$this->assertEquals('string|null', (string) $context->vars_in_scope['$a']);
|
||||
}
|
||||
|
||||
public function testRefineORedType()
|
||||
{
|
||||
$stmts = self::$parser->parse('<?php
|
||||
class A {
|
||||
public function doThing() : void
|
||||
{
|
||||
if ($this instanceof B || $this instanceof C) {
|
||||
if ($this instanceof B) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class B extends A {}
|
||||
class C extends A {}
|
||||
');
|
||||
|
||||
$file_checker = new FileChecker('somefile.php', $stmts);
|
||||
$context = new Context('somefile.php');
|
||||
$file_checker->check(true, true, $context);
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ use PhpParser\ParserFactory;
|
||||
use PHPUnit_Framework_TestCase;
|
||||
use Psalm\Checker\FileChecker;
|
||||
use Psalm\Checker\TypeChecker;
|
||||
use Psalm\Clause;
|
||||
use Psalm\Config;
|
||||
use Psalm\Context;
|
||||
use Psalm\Type;
|
||||
@ -190,6 +191,96 @@ class TypeReconciliationTest extends PHPUnit_Framework_TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testNegateFormula()
|
||||
{
|
||||
$formula = [
|
||||
new Clause(['$a' => ['!empty']])
|
||||
];
|
||||
|
||||
$negated_formula = TypeChecker::negateFormula($formula);
|
||||
|
||||
$this->assertSame(1, count($negated_formula));
|
||||
$this->assertSame(['$a' => ['empty']], $negated_formula[0]->possibilities);
|
||||
|
||||
$formula = [
|
||||
new Clause(['$a' => ['!empty'], '$b' => ['!empty']])
|
||||
];
|
||||
|
||||
$negated_formula = TypeChecker::negateFormula($formula);
|
||||
|
||||
$this->assertSame(2, count($negated_formula));
|
||||
$this->assertSame(['$a' => ['empty']], $negated_formula[0]->possibilities);
|
||||
$this->assertSame(['$b' => ['empty']], $negated_formula[1]->possibilities);
|
||||
|
||||
$formula = [
|
||||
new Clause(['$a' => ['!empty']]),
|
||||
new Clause(['$b' => ['!empty']]),
|
||||
];
|
||||
|
||||
$negated_formula = TypeChecker::negateFormula($formula);
|
||||
|
||||
$this->assertSame(1, count($negated_formula));
|
||||
$this->assertSame(['$a' => ['empty'], '$b' => ['empty']], $negated_formula[0]->possibilities);
|
||||
|
||||
$formula = [
|
||||
new Clause(['$a' => ['int', 'string'], '$b' => ['!empty']])
|
||||
];
|
||||
|
||||
$negated_formula = TypeChecker::negateFormula($formula);
|
||||
|
||||
$this->assertSame(3, count($negated_formula));
|
||||
$this->assertSame(['$a' => ['!int']], $negated_formula[0]->possibilities);
|
||||
$this->assertSame(['$a' => ['!string']], $negated_formula[1]->possibilities);
|
||||
$this->assertSame(['$b' => ['empty']], $negated_formula[2]->possibilities);
|
||||
}
|
||||
|
||||
public function testContainsClause()
|
||||
{
|
||||
$this->assertTrue(
|
||||
(new Clause(
|
||||
[
|
||||
'$a' => ['!empty'],
|
||||
'$b' => ['!empty']
|
||||
]
|
||||
))->contains(
|
||||
new Clause(
|
||||
[
|
||||
'$a' => ['!empty']
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
(new Clause(
|
||||
[
|
||||
'$a' => ['!empty']
|
||||
]
|
||||
))->contains(
|
||||
new Clause(
|
||||
[
|
||||
'$a' => ['!empty'],
|
||||
'$b' => ['!empty']
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testSimplifyCNF()
|
||||
{
|
||||
$formula = [
|
||||
new Clause(['$a' => ['!empty']]),
|
||||
new Clause(['$a' => ['empty'], '$b' => ['empty']])
|
||||
];
|
||||
|
||||
$simplified_formula = TypeChecker::simplifyCNF($formula);
|
||||
|
||||
$this->assertSame(2, count($simplified_formula));
|
||||
$this->assertSame(['$a' => ['!empty']], $simplified_formula[0]->possibilities);
|
||||
$this->assertSame(['$b' => ['empty']], $simplified_formula[1]->possibilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Psalm\Exception\CodeException
|
||||
* @expectedExceptionMessage TypeDoesNotContainType
|
||||
@ -354,9 +445,10 @@ class TypeReconciliationTest extends PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testTypeTransformation()
|
||||
{
|
||||
$this->markTestIncomplete('This currently fails');
|
||||
$stmts = self::$parser->parse('<?php
|
||||
$a = "5";
|
||||
|
||||
|
||||
if (is_numeric($a)) {
|
||||
if (is_int($a)) {
|
||||
echo $a;
|
||||
|
Loading…
Reference in New Issue
Block a user