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

Create Context, new if checks & file extension support

Add Context  object to hold in-scope vars, rework if checks accordingly with copious use of clone, and finally add support for handling different filetypes
This commit is contained in:
Matthew Brown 2016-06-20 00:38:13 -04:00
parent 5f2d9a66ee
commit 444c39097f
16 changed files with 715 additions and 759 deletions

View File

@ -120,16 +120,15 @@ class ClassChecker implements StatementsSource
}
if ($leftover_stmts) {
$scope_vars = [];
$possibly_in_scope_vars = [];
$context = new Context();
(new StatementsChecker($this))->check($leftover_stmts, $scope_vars, $possibly_in_scope_vars);
(new StatementsChecker($this))->check($leftover_stmts, $context);
}
if ($check_statements) {
// do the method checks after all class methods have been initialised
foreach ($method_checkers as $method_checker) {
$method_checker->check();
$method_checker->check(new Context());
$method_checker->checkReturnTypes();
}
}

View File

@ -298,7 +298,6 @@ class ClassMethodChecker extends FunctionChecker
'name' => $param->getName(),
'by_ref' => $param->isPassedByReference(),
'type' => $param_type ? Type::parseString($param_type) : Type::getMixed(),
'is_nullable' => $is_nullable
];
}
@ -427,36 +426,42 @@ class ClassMethodChecker extends FunctionChecker
$param_type = null;
if ($param->type) {
if (is_string($param->type)) {
$param_type = $param->type;
if ($param->type instanceof Type) {
$param_type = $param_type;
}
else {
if ($param->type instanceof PhpParser\Node\Name\FullyQualified) {
$param_type = implode('\\', $param->type->parts);
} elseif ($param->type->parts === ['self']) {
$param_type = $this->_absolute_class;
} else {
$param_type = ClassChecker::getAbsoluteClassFromString(implode('\\', $param->type->parts), $this->_namespace, $this->_aliased_classes);
if (is_string($param->type)) {
$param_type_string = $param->type;
}
elseif ($param->type instanceof PhpParser\Node\Name\FullyQualified) {
$param_type_string = implode('\\', $param->type->parts);
}
elseif ($param->type->parts === ['self']) {
$param_type_string = $this->_absolute_class;
}
else {
$param_type_string = ClassChecker::getAbsoluteClassFromString(implode('\\', $param->type->parts), $this->_namespace, $this->_aliased_classes);
}
}
}
$is_nullable = $param->default !== null &&
$is_nullable = $param->default !== null &&
$param->default instanceof \PhpParser\Node\Expr\ConstFetch &&
$param->default->name instanceof PhpParser\Node\Name &&
$param->default->name->parts = ['null'];
if ($is_nullable && $param_type) {
$param_type .= '|null';
if ($param_type_string) {
if ($is_nullable) {
$param_type_string .= '|null';
}
$param_type = Type::parseString($param_type_string);
}
}
}
self::$_method_params[$method_id][] = [
'name' => $param->name,
'by_ref' => $param->byRef,
'type' => $param_type ? Type::parseString($param_type) : Type::getMixed(),
'is_nullable' => $is_nullable
'type' => $param_type ?: Type::getMixed(),
];
}
}

View File

@ -1,7 +0,0 @@
<?php
namespace CodeInspector;
class CodeException extends \Exception {
}

View File

@ -18,6 +18,8 @@ class Config
protected $file_extensions = ['php'];
protected $filetype_handlers = [];
protected $issue_handlers = [];
protected $mock_classes = [];
@ -33,39 +35,35 @@ class Config
self::$_config = $this;
}
public static function loadFromXML($file_name)
public function loadFromXML($file_name)
{
$config = new self();
$file_contents = file_get_contents($file_name);
$config->base_dir = dirname($file_name) . '/';
$this->base_dir = dirname($file_name) . '/';
$config_xml = new SimpleXMLElement($file_contents);
if (isset($config_xml['stopOnError'])) {
$config->stop_on_error = $config_xml['stopOnError'] === 'true' || $config_xml['stopOnError'] === '1';
$this->stop_on_error = $config_xml['stopOnError'] === 'true' || $config_xml['stopOnError'] === '1';
}
if (isset($config_xml['useDocblockReturnType'])) {
$config->use_docblock_return_type = (bool) $config_xml['useDocblockReturnType'];
$this->use_docblock_return_type = (bool) $config_xml['useDocblockReturnType'];
}
if (isset($config_xml->inspectFiles)) {
$config->inspect_files = FileFilter::loadFromXML($config_xml->inspectFiles, true);
$this->inspect_files = FileFilter::loadFromXML($config_xml->inspectFiles, true);
}
if (isset($config_xml->fileExtensions)) {
$config->file_extensions = [];
$this->file_extensions = [];
foreach ($config_xml->fileExtensions->extension as $extension) {
$config->file_extensions[] = preg_replace('/^\.?/', '', $extension['name']);
}
$this->loadFileExtensions($config_xml->fileExtensions->extension);
}
if (isset($config_xml->mockClasses) && isset($config_xml->mockClasses->class)) {
foreach ($config_xml->mockClasses->class as $mock_class) {
$config->mock_classes[] = $mock_class['name'];
$this->mock_classes[] = $mock_class['name'];
}
}
@ -73,7 +71,14 @@ class Config
if (isset($config_xml->plugins) && isset($config_xml->plugins->plugin)) {
foreach ($config_xml->plugins->plugin as $plugin) {
$plugin_file_name = $plugin['filename'];
$loaded_plugin = require($config->base_dir . $plugin_file_name);
$path = $this->base_dir . $plugin_file_name;
if (!file_exists($path)) {
throw new \InvalidArgumentException('Cannot find file ' . $path);
}
$loaded_plugin = require($path);
if (!$loaded_plugin) {
throw new \InvalidArgumentException('Plugins must return an instance of that plugin at the end of the file - ' . $plugin_file_name . ' does not');
@ -83,14 +88,14 @@ class Config
throw new \InvalidArgumentException('Plugins must extend \CodeInspector\Plugin - ' . $plugin_file_name . ' does not');
}
$config->plugins[] = $loaded_plugin;
$this->plugins[] = $loaded_plugin;
}
}
if (isset($config_xml->issueHandler)) {
foreach ($config_xml->issueHandler->children() as $key => $issue_handler) {
if (isset($issue_handler->excludeFiles)) {
$config->issue_handlers[$key] = FileFilter::loadFromXML($issue_handler->excludeFiles, false);
$this->issue_handlers[$key] = FileFilter::loadFromXML($issue_handler->excludeFiles, false);
}
}
}
@ -108,6 +113,36 @@ class Config
return new self();
}
protected function loadFileExtensions($extensions)
{
foreach ($extensions as $extension) {
$extension_name = preg_replace('/^\.?/', '', $extension['name']);
$this->file_extensions[] = $extension_name;
if (isset($extension['filetypeHandler'])) {
$path = $this->base_dir . $extension['filetypeHandler'];
if (!file_exists($path)) {
throw new \ConfigException('Error parsing config: cannot find file ' . $path);
}
$declared_classes = FileChecker::getDeclaredClassesInFile($path);
if (count($declared_classes) !== 1) {
throw new \InvalidArgumentException('Filetype handlers must have exactly one class in the file - ' . $path . ' has ' . count($declared_classes));
}
require_once($path);
if (!is_subclass_of($declared_classes[0], 'CodeInspector\\FileChecker')) {
throw new \InvalidArgumentException('Filetype handlers must extend \CodeInspector\FileChecker - ' . $path . ' does not');
}
$this->filetype_handlers[$extension_name] = $declared_classes[0];
}
}
}
public function shortenFileName($file_name)
{
return preg_replace('/^' . preg_quote($this->base_dir, '/') . '/', '', $file_name);
@ -145,6 +180,11 @@ class Config
return $this->file_extensions;
}
public function getFiletypeHandlers()
{
return $this->filetype_handlers;
}
public function getMockClasses()
{
return $this->mock_classes;

View File

@ -0,0 +1,58 @@
<?php
namespace CodeInspector;
class Context
{
public $vars_in_scope = [];
public $vars_possibly_in_scope = [];
public function __clone()
{
foreach ($this->vars_in_scope as $key => &$type) {
$type = clone $type;
}
}
/**
* Updates the parent context, looking at the changes within a block
* and then applying those changes, where necessary, to the parent context
*
* @param Context $start_context
* @param Context $end_context
* @param bool $has_leaving_statements whether or not the parent scope is abandoned between $start_context and $end_context
* @return void
*/
public function update(Context $start_context, Context $end_context, $has_leaving_statments, array &$updated_vars)
{
foreach ($this->vars_in_scope as $var => &$context_type) {
$old_type = $start_context->vars_in_scope[$var];
// if we're leaving, we're effectively deleting the possibility of the if types
$new_type = !$has_leaving_statments ? $end_context->vars_in_scope[$var] : null;
// this is only true if there was some sort of type negation
if ((string)$context_type !== (string)$old_type) {
// if the type changed within the block of statements, process the replacement
if ((string)$old_type !== (string)$new_type) {
$context_type->substitute($old_type, $new_type);
$updated_vars[$var] = true;
}
}
}
}
public static function getRedefinedVars(Context $original_context, Context $new_context)
{
$redefined_vars = [];
foreach ($original_context->vars_in_scope as $var => $context_type) {
if ((string)$new_context->vars_in_scope[$var] !== (string)$context_type) {
$redefined_vars[$var] = $new_context->vars_in_scope[$var];
}
}
return $redefined_vars;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace CodeInspector\Exception;
class CodeException extends \Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace CodeInspector\Exception;
class ConfigException extends \Exception
{
}

View File

@ -17,7 +17,7 @@ class ExceptionHandler
$error_message = $error_class_name . ' - ' . $e->getMessage();
if ($config->stop_on_error) {
throw new CodeException($error_message);
throw new Exception\CodeException($error_message);
}
echo $error_message . PHP_EOL;

View File

@ -20,6 +20,8 @@ class FileChecker implements StatementsSource
protected $_preloaded_statements = [];
protected $_declared_classes = [];
protected static $_cache_dir = null;
protected static $_file_checkers = [];
protected static $_functions = [];
@ -51,6 +53,7 @@ class FileChecker implements StatementsSource
if ($stmt instanceof PhpParser\Node\Stmt\Class_) {
if ($check_classes) {
$class_checker = ClassChecker::getClassCheckerFromClass($stmt->name) ?: new ClassChecker($stmt, $this, $stmt->name);
$this->_declared_classes[] = $class_checker->getAbsoluteClass();
$class_checker->check($check_class_statements);
}
@ -68,6 +71,7 @@ class FileChecker implements StatementsSource
$namespace_checker = new NamespaceChecker($stmt, $this);
$this->_namespace_aliased_classes[$namespace_name] = $namespace_checker->check($check_classes, $check_class_statements);
$this->_declared_classes = array_merge($namespace_checker->getDeclaredClasses());
} elseif ($stmt instanceof PhpParser\Node\Stmt\Use_) {
foreach ($stmt->uses as $use) {
@ -81,33 +85,12 @@ class FileChecker implements StatementsSource
if ($leftover_stmts) {
$statments_checker = new StatementsChecker($this);
$existing_vars = [];
$existing_vars_in_scope = [];
$statments_checker->check($leftover_stmts, $existing_vars, $existing_vars_in_scope);
$statments_checker->check($leftover_stmts, new Context());
}
return $stmts;
}
public function checkWithClass($class_name, $method_vars = [])
{
$stmts = self::getStatements($this->_real_file_name);
$class_method = new PhpParser\Node\Stmt\ClassMethod($class_name, ['stmts' => $stmts]);
if ($method_vars) {
foreach ($method_vars as $method_var => $type) {
$class_method->params[] = new PhpParser\Node\Param($method_var, null, $type);
}
}
$class = new PhpParser\Node\Stmt\Class_($class_name);
$class_checker = new ClassChecker($class, $this, $class_name);
(new ClassMethodChecker($class_method, $class_checker))->check();
}
public static function getAbsoluteClassFromNameInFile($class, $namespace, $file_name)
{
if (isset(self::$_file_checkers[$file_name])) {
@ -122,6 +105,33 @@ class FileChecker implements StatementsSource
return ClassChecker::getAbsoluteClassFromString($class, $namespace, $aliased_classes);
}
/**
* Gets a list of the classes declared
* @return array<string>
*/
public function getDeclaredClasses()
{
return $this->_declared_classes;
}
/**
* Gets a list of the classes declared in that file
* @param string $file_name
* @return array<string>
*/
public static function getDeclaredClassesInFile($file_name)
{
if (isset(self::$_file_checkers[$file_name])) {
$file_checker = self::$_file_checkers[$file_name];
}
else {
$file_checker = new FileChecker($file_name);
$file_checker->check(false);
}
return $file_checker->getDeclaredClasses();
}
/**
* @return array<\PhpParser\Node>
*/

View File

@ -35,22 +35,22 @@ class FunctionChecker implements StatementsSource
$this->_source = $source;
}
public function check(&$vars_in_scope = [], &$vars_possibly_in_scope = [], $check_methods = true)
public function check(Context $context, $check_methods = true)
{
if ($this->_function->stmts) {
if ($this instanceof ClassMethodChecker) {
if (ClassChecker::getThisClass()) {
$hash = $this->getMethodId() . json_encode([$vars_in_scope, $vars_possibly_in_scope]);
$hash = $this->getMethodId() . json_encode([$context->vars_in_scope, $context->vars_possibly_in_scope]);
// if we know that the function has no effects on vars, we don't bother rechecking
if (isset(self::$_no_effects_hashes[$hash])) {
list($vars_in_scope, $vars_possibly_in_scope) = self::$_no_effects_hashes[$hash];
list($context->vars_in_scope, $context->vars_possibly_in_scope) = self::$_no_effects_hashes[$hash];
return;
}
}
else {
$vars_in_scope['this'] = new Type\Union([new Type\Atomic($this->_absolute_class)]);
$context->vars_in_scope['this'] = new Type\Union([new Type\Atomic($this->_absolute_class)]);
}
}
@ -58,7 +58,7 @@ class FunctionChecker implements StatementsSource
foreach ($this->_function->params as $param) {
if ($param->type) {
if (is_object($param->type)) {
if ($param->type instanceof PhpParser\Node\Name) {
if (!in_array($param->type->parts[0], ['self', 'parent'])) {
ClassChecker::checkClassName($param->type, $this->_namespace, $this->_aliased_classes, $this->_file_name);
}
@ -70,54 +70,59 @@ class FunctionChecker implements StatementsSource
$param->default->name instanceof PhpParser\Node\Name &&
$param->default->name->parts = ['null'];
if ($param->type && is_object($param->type)) {
$param_class = $param->type->parts === ['self'] ?
$this->_absolute_class :
ClassChecker::getAbsoluteClassFromName($param->type, $this->_namespace, $this->_aliased_classes);
$param_type = new Type\Union([new Type\Atomic($param_class)]);
if ($is_nullable) {
$param_type->types['null'] = Type::getNull(false);
if ($param->type) {
if ($param->type instanceof Type) {
$context->vars_in_scope[$param->name] = clone $param->type;
}
else {
if (is_string($param->type)) {
$param_type_string = $param->type;
}
elseif ($param->type instanceof PhpParser\Node\Name) {
$param_type_string = $param->type->parts === ['self']
? $this->_absolute_class
: ClassChecker::getAbsoluteClassFromName($param->type, $this->_namespace, $this->_aliased_classes);
}
$vars_in_scope[$param->name] = $param_type;
}
elseif (is_string($param->type)) {
$vars_in_scope[$param->name] = Type::parseString($param->type);
if ($is_nullable) {
$param_type_string .= '|null';
}
$context->vars_in_scope[$param->name] = Type::parseString($param_type_string);
}
}
else {
$vars_in_scope[$param->name] = Type::getMixed();
$context->vars_in_scope[$param->name] = Type::getMixed();
}
$vars_possibly_in_scope[$param->name] = true;
$context->vars_possibly_in_scope[$param->name] = true;
$statements_checker->registerVariable($param->name, $param->getLine());
}
$statements_checker->check($this->_function->stmts, $vars_in_scope, $vars_possibly_in_scope);
$statements_checker->check($this->_function->stmts, $context);
if (isset($this->_return_vars_in_scope[''])) {
$vars_in_scope = TypeChecker::combineKeyedTypes($vars_in_scope, $this->_return_vars_in_scope['']);
$context->vars_in_scope = TypeChecker::combineKeyedTypes($context->vars_in_scope, $this->_return_vars_in_scope['']);
}
if (isset($this->_return_vars_possibly_in_scope[''])) {
$vars_possibly_in_scope = array_merge($vars_possibly_in_scope, $this->_return_vars_possibly_in_scope['']);
$context->vars_possibly_in_scope = array_merge($context->vars_possibly_in_scope, $this->_return_vars_possibly_in_scope['']);
}
foreach ($vars_in_scope as $var => $type) {
foreach ($context->vars_in_scope as $var => $type) {
if (strpos($var, 'this->') !== 0) {
unset($vars_in_scope[$var]);
unset($context->vars_in_scope[$var]);
}
}
foreach ($vars_possibly_in_scope as $var => $type) {
foreach ($context->vars_possibly_in_scope as $var => $type) {
if (strpos($var, 'this->') !== 0) {
unset($vars_possibly_in_scope[$var]);
unset($context->vars_possibly_in_scope[$var]);
}
}
if (ClassChecker::getThisClass() && $this instanceof ClassMethodChecker) {
self::$_no_effects_hashes[$hash] = [$vars_in_scope, $vars_possibly_in_scope];
self::$_no_effects_hashes[$hash] = [$context->vars_in_scope, $context->vars_possibly_in_scope];
}
}
}
@ -125,23 +130,23 @@ class FunctionChecker implements StatementsSource
/**
* Adds return types for the given function
* @param string $return_type
* @param array<Type> $vars_in_scope
* @param array<bool> $vars_possibly_in_scope
* @param array<Type> $context->vars_in_scope
* @param array<bool> $context->vars_possibly_in_scope
*/
public function addReturnTypes($return_type, $vars_in_scope, $vars_possibly_in_scope)
public function addReturnTypes($return_type, Context $context)
{
if (isset($this->_return_vars_in_scope[$return_type])) {
$this->_return_vars_in_scope[$return_type] = TypeChecker::combineKeyedTypes($vars_in_scope, $this->_return_vars_in_scope[$return_type]);
$this->_return_vars_in_scope[$return_type] = TypeChecker::combineKeyedTypes($context->vars_in_scope, $this->_return_vars_in_scope[$return_type]);
}
else {
$this->_return_vars_in_scope[$return_type] = $vars_in_scope;
$this->_return_vars_in_scope[$return_type] = $context->vars_in_scope;
}
if (isset($this->_return_vars_possibly_in_scope[$return_type])) {
$this->_return_vars_possibly_in_scope[$return_type] = array_merge($vars_possibly_in_scope, $this->_return_vars_possibly_in_scope[$return_type]);
$this->_return_vars_possibly_in_scope[$return_type] = array_merge($context->vars_possibly_in_scope, $this->_return_vars_possibly_in_scope[$return_type]);
}
else {
$this->_return_vars_possibly_in_scope[$return_type] = $vars_possibly_in_scope;
$this->_return_vars_possibly_in_scope[$return_type] = $context->vars_possibly_in_scope;
}
}

View File

@ -2,6 +2,6 @@
namespace CodeInspector\Issue;
class InvalidArrayAssignment extends CodeArray
class InvalidArrayAssignment extends CodeError
{
}

View File

@ -8,7 +8,7 @@ class NamespaceChecker implements StatementsSource
{
protected $_namespace;
protected $_namespace_name;
protected $_contained_classes = [];
protected $_declared_classes = [];
protected $_aliased_classes = [];
protected $_file_name;
@ -26,7 +26,7 @@ class NamespaceChecker implements StatementsSource
foreach ($this->_namespace->stmts as $stmt) {
if ($stmt instanceof PhpParser\Node\Stmt\Class_) {
$absolute_class = ClassChecker::getAbsoluteClassFromString($stmt->name, $this->_namespace_name, []);
$this->_contained_classes[$absolute_class] = 1;
$this->_declared_classes[$absolute_class] = 1;
if ($check_classes) {
$class_checker = ClassChecker::getClassCheckerFromClass($absolute_class) ?: new ClassChecker($stmt, $this, $absolute_class);
@ -62,9 +62,18 @@ class NamespaceChecker implements StatementsSource
return $this->_aliased_classes;
}
/**
* Gets a list of the classes declared
* @return array<string>
*/
public function getDeclaredClasses()
{
return array_keys($this->_declared_classes);
}
public function containsClass($class_name)
{
return isset($this->_contained_classes[$class_name]);
return isset($this->_declared_classes[$class_name]);
}
public function getNamespace()

View File

@ -14,7 +14,7 @@ abstract class Plugin
* @param string $file_name
* @return null|false
*/
public function checkExpression(PhpParser\Node\Expr $stmt, array &$vars_in_scope, array &$vars_possibly_in_scope, $file_name)
public function checkExpression(PhpParser\Node\Expr $stmt, Context $context, $file_name)
{
return;
}
@ -27,7 +27,7 @@ abstract class Plugin
* @param string $file_name
* @return null|false
*/
public function checkStatement(PhpParser\Node $stmt, array &$vars_in_scope, array &$vars_possibly_in_scope, $file_name)
public function checkStatement(PhpParser\Node $stmt, Context $context, $file_name)
{
return;
}

View File

@ -19,6 +19,7 @@ class ProjectChecker
$config = Config::getInstance();
$file_extensions = $config->getFileExtensions();
$filetype_handlers = $config->getFiletypeHandlers();
$base_dir = $config->getBaseDir();
/** @var RecursiveDirectoryIterator */
@ -29,21 +30,50 @@ class ProjectChecker
while ($iterator->valid()) {
if (!$iterator->isDot()) {
if (in_array($iterator->getExtension(), $file_extensions)) {
$files[] = $iterator->getRealPath();
$extension = $iterator->getExtension();
if (in_array($extension, $file_extensions)) {
$file_name = $iterator->getRealPath();
if ($debug) {
echo 'Checking ' . $file_name . PHP_EOL;
}
if (isset($filetype_handlers[$extension])) {
/** @var FileChecker */
$file_checker = new $filetype_handlers[$extension]($file_name);
}
else {
$file_checker = new FileChecker($file_name);
}
$file_checker->check(true);
}
}
$iterator->next();
}
}
foreach ($files as $file_name) {
if ($debug) {
echo 'Checking ' . $file_name . PHP_EOL;
}
$file_checker = new FileChecker($file_name);
$file_checker->check(true);
public static function checkFile($file_name, $debug = false)
{
if ($debug) {
echo 'Checking ' . $file_name . PHP_EOL;
}
$config = Config::getInstance();
$extension = array_pop(explode('.', $file_name));
$filetype_handlers = $config->getFiletypeHandlers();
if (isset($filetype_handlers[$extension])) {
/** @var FileChecker */
$file_checker = new $filetype_handlers[$extension]($file_name);
}
else {
$file_checker = new FileChecker($file_name);
}
$file_checker->check(true);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -40,19 +40,35 @@ class Union extends Type
);
}
public function removeType($type_string) {
public function removeType($type_string)
{
unset($this->types[$type_string]);
}
public function hasType($type_string) {
public function hasType($type_string)
{
return isset($this->types[$type_string]);
}
public function removeObjects() {
public function removeObjects()
{
foreach ($this->types as $key => $type) {
if ($key[0] === strtoupper($key[0])) {
unset($this->types[$key]);
}
}
}
public function substitute(Union $old_type, Union $new_type = null)
{
foreach ($old_type->types as $old_type_part) {
$this->removeType($old_type_part->value);
}
if ($new_type) {
foreach ($new_type->types as $key => $new_type_part) {
$this->types[$key] = $new_type_part;
}
}
}
}