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

Add support for callable(...) syntax

Ref #580
This commit is contained in:
Matthew Brown 2018-03-26 22:13:10 -04:00
parent 871a91c850
commit 58115599a1
18 changed files with 455 additions and 54 deletions

View File

@ -68,6 +68,7 @@
<PossiblyUnusedMethod>
<errorLevel type="suppress">
<directory name="tests" />
<file name="src/Psalm/Type/Atomic/CallableTrait.php" />
<file name="src/Psalm/Type/Atomic/GenericTrait.php" />
<file name="src/Psalm/Plugin.php" />
<referencedMethod name="Psalm\Codebase::getParentInterfaces" />

View File

@ -13,7 +13,7 @@ use Psalm\VarDocblockComment;
class CommentChecker
{
const TYPE_REGEX = '(\??\\\?[A-Za-z][\(\)A-Za-z0-9_&\<,\>\[\]\-\{\}:|?\\\\]*|\$[a-zA-Z_0-9_&\<,\>\|\[\]-\{\}:]+)';
const TYPE_REGEX = '(\??\\\?[A-Za-z][\(\)A-Za-z0-9_&\<\.=,\>\[\]\-\{\}:|?\\\\]*|\$[a-zA-Z_0-9_]+)';
/**
* @param string $comment
@ -383,8 +383,13 @@ class CommentChecker
$type = '';
for ($i = 0; $i < strlen($return_block); ++$i) {
$expects_callable_return = false;
$return_block = preg_replace('/[ \t]+/', ' ', $return_block);
for ($i = 0, $l = strlen($return_block); $i < $l; ++$i) {
$char = $return_block[$i];
$next_char = $i < $l - 1 ? $return_block[$i + 1] : null;
if ($char === '[' || $char === '{' || $char === '(' || $char === '<') {
$brackets .= $char;
@ -404,10 +409,21 @@ class CommentChecker
continue;
}
if ($next_char === ':') {
++$i;
$expects_callable_return = true;
continue;
}
if ($expects_callable_return) {
$expects_callable_return = false;
continue;
}
$remaining = trim(substr($return_block, $i + 1));
if ($remaining) {
return array_merge([$type], preg_split('/[\s\t]+/', $remaining));
return array_merge([$type], explode(' ', $remaining));
}
return [$type];

View File

@ -373,7 +373,7 @@ class FunctionChecker extends FunctionLikeChecker
&& ($closure_atomic_type = $function_call_arg->value->inferredType->getTypes()['Closure'])
&& $closure_atomic_type instanceof Type\Atomic\Fn
) {
$closure_return_type = $closure_atomic_type->return_type;
$closure_return_type = $closure_atomic_type->return_type ?: Type::getMixed();
if ($closure_return_type->isVoid()) {
IssueBuffer::accepts(
@ -591,7 +591,7 @@ class FunctionChecker extends FunctionLikeChecker
&& ($closure_atomic_type = $function_call_arg->value->inferredType->getTypes()['Closure'])
&& $closure_atomic_type instanceof Type\Atomic\Fn
) {
$closure_return_type = $closure_atomic_type->return_type;
$closure_return_type = $closure_atomic_type->return_type ?: Type::getMixed();
if ($closure_return_type->isVoid()) {
IssueBuffer::accepts(

View File

@ -146,13 +146,13 @@ class FunctionCallChecker extends \Psalm\Checker\Statements\Expression\CallCheck
if ($var_type_part instanceof Type\Atomic\Fn) {
$function_params = $var_type_part->params;
if (isset($stmt->inferredType)) {
if (isset($stmt->inferredType) && $var_type_part->return_type) {
$stmt->inferredType = Type::combineUnionTypes(
$stmt->inferredType,
$var_type_part->return_type
);
} else {
$stmt->inferredType = $var_type_part->return_type;
$stmt->inferredType = $var_type_part->return_type ?: Type::getMixed();
}
$function_exists = true;

View File

@ -1008,6 +1008,10 @@ class CallChecker
}
foreach ($closure_types as $closure_type) {
if ($closure_type->params === null) {
continue;
}
if (self::checkArrayFunctionClosureTypeArgs(
$statements_checker,
$method_id,
@ -1043,6 +1047,10 @@ class CallChecker
$closure_params = $closure_type->params;
if ($closure_params === null) {
throw new \UnexpectedValueException('Closure params should not be null here');
}
$required_param_count = 0;
foreach ($closure_params as $i => $param) {

View File

@ -83,4 +83,9 @@ class FunctionLikeParameter
$this->type_location = $type_location;
$this->signature_type_location = $type_location;
}
public function __toString()
{
return ($this->is_variadic ? '...' : '') . $this->type . ($this->is_optional ? '=' : '');
}
}

View File

@ -25,8 +25,9 @@ class ClassLikeStorageCacheProvider
$storage_dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Storage' . DIRECTORY_SEPARATOR;
$dependent_files = [
$storage_dir . 'ClassLikeStorage.php',
$storage_dir . 'FileStorage.php',
$storage_dir . 'FunctionLikeStorage.php',
$storage_dir . 'ClassLikeStorage.php',
$storage_dir . 'MethodStorage.php',
];

View File

@ -29,6 +29,7 @@ class FileStorageCacheProvider
$storage_dir . 'FunctionLikeStorage.php',
$storage_dir . 'ClassLikeStorage.php',
$storage_dir . 'MethodStorage.php',
dirname(__DIR__) . DIRECTORY_SEPARATOR . 'FunctionLikeParameter.php',
];
foreach ($dependent_files as $dependent_file_path) {

View File

@ -2,10 +2,12 @@
namespace Psalm;
use Psalm\Exception\TypeParseTreeException;
use Psalm\FunctionLikeParameter;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\ObjectLike;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TBool;
use Psalm\Type\Atomic\TCallable;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TEmpty;
use Psalm\Type\Atomic\TFalse;
@ -43,7 +45,7 @@ abstract class Type
public static function parseString($type_string, $php_compatible = false)
{
// remove all unacceptable characters
$type_string = preg_replace('/[^A-Za-z0-9\-_\\\\&|\? \<\>\{\}:,\]\[\(\)\$]/', '', trim($type_string));
$type_string = preg_replace('/[^A-Za-z0-9\-_\\\\&|\? \<\>\{\}=:\.,\]\[\(\)\$]/', '', trim($type_string));
$type_string = preg_replace('/\?(?=[a-zA-Z])/', 'null|', $type_string);
@ -254,12 +256,66 @@ abstract class Type
return new ObjectLike($properties);
}
if ($parse_tree instanceof ParseTree\CallableWithReturnTypeTree) {
$callable_type = self::getTypeFromTree($parse_tree->children[0], false);
if (!$callable_type instanceof TCallable) {
throw new \InvalidArgumentException('Parsing callable tree node should return TCallable');
}
$return_type = self::getTypeFromTree($parse_tree->children[1], false);
$callable_type->return_type = $return_type instanceof Union ? $return_type : new Union([$return_type]);
return $callable_type;
}
if ($parse_tree instanceof ParseTree\CallableTree) {
$params = array_map(
/**
* @return FunctionLikeParameter
*/
function (ParseTree $child_tree) {
$is_variadic = false;
$is_optional = false;
if ($child_tree instanceof ParseTree\CallableParamTree) {
$tree_type = self::getTypeFromTree($child_tree->children[0], false);
$is_variadic = $child_tree->variadic;
$is_optional = $child_tree->has_default;
} else {
$tree_type = self::getTypeFromTree($child_tree, false);
}
$tree_type = $tree_type instanceof Union ? $tree_type : new Union([$tree_type]);
return new FunctionLikeParameter(
'',
false,
$tree_type,
null,
null,
$is_optional,
false,
$is_variadic
);
},
$parse_tree->children
);
if (in_array($parse_tree->value, ['closure', '\closure'], false)) {
return new Type\Atomic\Fn('Closure', $params);
}
return new TCallable($parse_tree->value, $params);
}
if ($parse_tree instanceof ParseTree\EncapsulationTree) {
return self::getTypeFromTree($parse_tree->children[0], false);
}
if (!$parse_tree instanceof ParseTree\Value) {
throw new \InvalidArgumentException('Unrecognised parse tree type');
throw new \InvalidArgumentException('Unrecognised parse tree type ' . get_class($parse_tree));
}
$atomic_type = self::fixScalarTerms($parse_tree->value, $php_compatible);
@ -289,25 +345,29 @@ abstract class Type
// index of last type token
$rtc = 0;
foreach (str_split($return_type) as $char) {
$chars = str_split($return_type);
for ($i = 0, $c = count($chars); $i < $c; ++$i) {
$char = $chars[$i];
if ($was_char) {
$return_type_tokens[++$rtc] = '';
}
if ($char === '<' ||
$char === '>' ||
$char === '|' ||
$char === '?' ||
$char === ',' ||
$char === '{' ||
$char === '}' ||
$char === '[' ||
$char === ']' ||
$char === '(' ||
$char === ')' ||
$char === ' ' ||
$char === '&' ||
$char === ':'
if ($char === '<'
|| $char === '>'
|| $char === '|'
|| $char === '?'
|| $char === ','
|| $char === '{'
|| $char === '}'
|| $char === '['
|| $char === ']'
|| $char === '('
|| $char === ')'
|| $char === ' '
|| $char === '&'
|| $char === ':'
|| $char === '='
) {
if ($return_type_tokens[$rtc] === '') {
$return_type_tokens[$rtc] = $char;
@ -316,6 +376,20 @@ abstract class Type
}
$was_char = true;
} elseif ($char === '.') {
if ($i + 1 > $c || $chars[$i + 1] !== '.' || $chars[$i + 2] !== '.') {
throw new TypeParseTreeException('Unexpected token ' . $char);
}
if ($return_type_tokens[$rtc] === '') {
$return_type_tokens[$rtc] = '...';
} else {
$return_type_tokens[++$rtc] = '...';
}
$was_char = true;
$i += 2;
} else {
$return_type_tokens[$rtc] .= $char;
$was_char = false;

View File

@ -0,0 +1,101 @@
<?php
namespace Psalm\Type\Atomic;
use Psalm\FunctionLikeParameter;
use Psalm\Type\Union;
trait CallableTrait
{
/**
* @var array<int, FunctionLikeParameter>|null
*/
public $params = [];
/**
* @var Union|null
*/
public $return_type;
/**
* @var string
*/
public $value;
/**
* Constructs a new instance of a generic type
*
* @param string $value
* @param array<int, FunctionLikeParameter> $params
* @param Union $return_type
*/
public function __construct($value = 'callable', array $params = null, Union $return_type = null)
{
$this->value = $value;
$this->params = $params;
$this->return_type = $return_type;
}
/**
* @param string|null $namespace
* @param array<string> $aliased_classes
* @param string|null $this_class
* @param bool $use_phpdoc_format
*
* @return string
*/
public function toNamespacedString($namespace, array $aliased_classes, $this_class, $use_phpdoc_format)
{
if ($use_phpdoc_format) {
if ($this instanceof TNamedObject) {
return parent::toNamespacedString($namespace, $aliased_classes, $this_class, true);
}
return $this->value;
}
$param_string = '';
$return_type_string = '';
if ($this->params !== null) {
$param_string = '(' . implode(
', ',
array_map(
/**
* @return string
*/
function (FunctionLikeParameter $param) use ($namespace, $aliased_classes, $this_class) {
if (!$param->type) {
throw new \UnexpectedValueException('Param type must not be null');
}
$type_string = $param->type->toNamespacedString(
$namespace,
$aliased_classes,
$this_class,
false
);
return ($param->is_variadic ? '...' : '') . $type_string . ($param->is_optional ? '=' : '');
},
$this->params
)
) . ')';
}
if ($this->return_type !== null) {
$return_type_string = ' : ' . $this->return_type->toNamespacedString(
$namespace,
$aliased_classes,
$this_class,
false
);
}
if ($this instanceof TNamedObject) {
return parent::toNamespacedString($namespace, $aliased_classes, $this_class, true)
. $param_string . $return_type_string;
}
return 'callable' . $param_string . $return_type_string;
}
}

View File

@ -9,29 +9,7 @@ use Psalm\Type\Union;
*/
class Fn extends TNamedObject
{
/**
* @var array<int, FunctionLikeParameter>
*/
public $params = [];
/**
* @var Union
*/
public $return_type;
/**
* Constructs a new instance of a generic type
*
* @param string $value
* @param array<int, FunctionLikeParameter> $params
* @param Union $return_type
*/
public function __construct($value, array $params, Union $return_type)
{
$this->value = 'Closure';
$this->params = $params;
$this->return_type = $return_type;
}
use CallableTrait;
/**
* @return string

View File

@ -3,9 +3,22 @@ namespace Psalm\Type\Atomic;
class TCallable extends \Psalm\Type\Atomic
{
use CallableTrait;
public function __toString()
{
return 'callable';
$param_string = '';
$return_type_string = '';
if ($this->params !== null) {
$param_string = '(' . implode(', ', $this->params) . ')';
}
if ($this->return_type !== null) {
$return_type_string = ' : ' . $this->return_type;
}
return 'callable' . $param_string . $return_type_string;
}
/**
@ -23,7 +36,7 @@ class TCallable extends \Psalm\Type\Atomic
* @param int $php_major_version
* @param int $php_minor_version
*
* @return null|string
* @return string
*/
public function toPhpString(
$namespace,
@ -32,7 +45,7 @@ class TCallable extends \Psalm\Type\Atomic
$php_major_version,
$php_minor_version
) {
return null;
return 'callable';
}
public function canBeFullyExpressedInPhp()

View File

@ -105,7 +105,8 @@ class ParseTree
}
$current_leaf = $current_leaf->parent;
} while (!$current_leaf instanceof ParseTree\EncapsulationTree);
} while (!$current_leaf instanceof ParseTree\EncapsulationTree
&& !$current_leaf instanceof ParseTree\CallableTree);
break;
@ -142,6 +143,7 @@ class ParseTree
if ($context_node instanceof ParseTree\GenericTree
|| $context_node instanceof ParseTree\ObjectLikeTree
|| $context_node instanceof ParseTree\CallableTree
) {
$context_node = $context_node->parent;
}
@ -149,6 +151,7 @@ class ParseTree
while ($context_node
&& !$context_node instanceof ParseTree\GenericTree
&& !$context_node instanceof ParseTree\ObjectLikeTree
&& !$context_node instanceof ParseTree\CallableTree
) {
$context_node = $context_node->parent;
}
@ -161,11 +164,70 @@ class ParseTree
break;
case '...':
if (!$current_leaf instanceof ParseTree\CallableTree) {
throw new TypeParseTreeException('Unexpected token ' . $type_token);
}
$new_leaf = new ParseTree\CallableParamTree($current_leaf);
$new_leaf->variadic = true;
$current_leaf->children[] = $new_leaf;
$current_leaf = $new_leaf;
break;
case '=':
$current_parent = $current_leaf->parent;
while ($current_parent
&& !$current_parent instanceof ParseTree\CallableTree
&& !$current_parent instanceof ParseTree\CallableParamTree
) {
$current_leaf = $current_parent;
$current_parent = $current_parent->parent;
}
if (!$current_parent || !$current_leaf) {
throw new TypeParseTreeException('Unexpected token ' . $type_token);
}
if ($current_parent instanceof ParseTree\CallableParamTree) {
throw new TypeParseTreeException('Cannot have variadic param with a default');
}
$new_leaf = new ParseTree\CallableParamTree($current_parent);
$new_leaf->has_default = true;
$new_leaf->children = [$current_leaf];
$current_leaf->parent = $new_leaf;
array_pop($current_parent->children);
$current_parent->children[] = $new_leaf;
$current_leaf = $new_leaf;
break;
case ':':
$current_parent = $current_leaf->parent;
if ($current_leaf instanceof ParseTree\CallableTree) {
$new_parent_leaf = new ParseTree\CallableWithReturnTypeTree($current_parent);
$current_leaf->parent = $new_parent_leaf;
$new_parent_leaf->children = [$current_leaf];
if ($current_parent) {
array_pop($current_parent->children);
$current_parent->children[] = $new_parent_leaf;
} else {
$parse_tree = $new_parent_leaf;
}
$current_leaf = $new_parent_leaf;
break;
}
if ($current_parent && $current_parent instanceof ParseTree\ObjectLikePropertyTree) {
continue;
break;
}
if (!$current_parent) {
@ -261,7 +323,18 @@ class ParseTree
break;
case '(':
throw new TypeParseTreeException('Cannot process bracket yet');
if (!in_array($type_token, ['closure', 'callable', '\closure'])) {
throw new TypeParseTreeException(
'Bracket must be preceded by “Closure” or “callable”'
);
}
$new_leaf = new ParseTree\CallableTree(
$type_token,
$new_parent
);
++$i;
break;
default:
if ($type_token === '$this') {

View File

@ -0,0 +1,15 @@
<?php
namespace Psalm\Type\ParseTree;
class CallableParamTree extends \Psalm\Type\ParseTree
{
/**
* @var bool
*/
public $variadic = false;
/**
* @var bool
*/
public $has_default = false;
}

View File

@ -0,0 +1,20 @@
<?php
namespace Psalm\Type\ParseTree;
class CallableTree extends \Psalm\Type\ParseTree
{
/**
* @var string
*/
public $value;
/**
* @param string $value
* @param \Psalm\Type\ParseTree|null $parent
*/
public function __construct($value, \Psalm\Type\ParseTree $parent = null)
{
$this->value = $value;
$this->parent = $parent;
}
}

View File

@ -0,0 +1,6 @@
<?php
namespace Psalm\Type\ParseTree;
class CallableWithReturnTypeTree extends \Psalm\Type\ParseTree
{
}

View File

@ -585,6 +585,10 @@ class FunctionCallTest extends TestCase
'<?php
$a = function() use ($argv) : void {};',
],
'SKIPPED-implodeMultiDimensionalArray' => [
'<?php
$urls = array_map("implode", [["a", "b"]]);',
],
];
}

View File

@ -300,4 +300,89 @@ class TypeParseTest extends TestCase
(string)Type::parseString('array{a:int, b?:int}')
);
}
/**
* @return void
*/
public function testCallable()
{
$this->assertSame(
'callable(int, string) : void',
(string)Type::parseString('callable(int, string) : void')
);
}
/**
* @return void
*/
public function testCallableWithUnionLastType()
{
$this->assertSame(
'callable(int, int|string) : void',
(string)Type::parseString('callable(int, int|string) : void')
);
}
/**
* @return void
*/
public function testCallableWithVariadic()
{
$this->assertSame(
'callable(int, ...string) : void',
(string)Type::parseString('callable(int, ...string) : void')
);
}
/**
* @expectedException \Psalm\Exception\TypeParseTreeException
*
* @return void
*/
public function testCallableWithBadVariadic()
{
Type::parseString('callable(int, ..string) : void');
}
/**
* @expectedException \Psalm\Exception\TypeParseTreeException
*
* @return void
*/
public function testCallableWithVariadicAndDefault()
{
Type::parseString('callable(int, ...string=) : void');
}
/**
* @expectedException \Psalm\Exception\TypeParseTreeException
*
* @return void
*/
public function testBadVariadic()
{
Type::parseString('...string');
}
/**
* @return void
*/
public function testCallableWithDefault()
{
$this->assertSame(
'callable(int, string=) : void',
(string)Type::parseString('callable(int, string=) : void')
);
}
/**
* @return void
*/
public function testCallableWithoutReturn()
{
$this->assertSame(
'callable(int, string)',
(string)Type::parseString('callable(int, string)')
);
}
}