1
0
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:
Matthew Brown 2016-12-28 13:28:41 -05:00 committed by GitHub
commit 4ee1a588ad
11 changed files with 761 additions and 172 deletions

View File

@ -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()

View File

@ -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);
}
}

View File

@ -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',

View File

@ -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,

View File

@ -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']);
}
*/

View File

@ -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
View 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) : '');
}
}

View File

@ -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 = [];

View File

@ -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));

View File

@ -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);
}
}

View File

@ -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;