Skip to content

Commit

Permalink
feature: introduce callable-object intersection type object&callable
Browse files Browse the repository at this point in the history
This allows devs to annotate that they expect a callable object.
In addition to this, it can also verify return and argument types.

Signed-off-by: Maximilian Bösing <[email protected]>
  • Loading branch information
boesing committed Apr 3, 2023
1 parent 11e90e7 commit ee68f16
Show file tree
Hide file tree
Showing 17 changed files with 371 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\Scalar;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TFalse;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Atomic\TIterable;
Expand Down Expand Up @@ -750,6 +751,7 @@ public static function handleIterable(
foreach ($iterator_atomic_types as $iterator_atomic_type) {
if ($iterator_atomic_type instanceof TTemplateParam
|| $iterator_atomic_type instanceof TObjectWithProperties
|| $iterator_atomic_type instanceof TCallableObject
) {
throw new UnexpectedValueException('Shouldn’t get a generic param here');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -723,8 +723,17 @@ private static function getAnalyzeNamedExpression(
),
$statements_analyzer->getSuppressedIssues(),
);
} elseif ($var_type_part instanceof TCallableObject
|| $var_type_part instanceof TCallableString
} elseif ($var_type_part instanceof TCallableObject) {
$has_valid_function_call_type = true;
self::analyzeInvokeCall(
$statements_analyzer,
$stmt,
$real_stmt,
$function_name,
$context,
$var_type_part,
);
} elseif ($var_type_part instanceof TCallableString
|| ($var_type_part instanceof TNamedObject && $var_type_part->value === 'Closure')
|| ($var_type_part instanceof TObjectWithProperties && isset($var_type_part->methods['__invoke']))
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
use Psalm\Storage\ClassLikeStorage;
use Psalm\Type;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TCallable;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TClosure;
use Psalm\Type\Atomic\TEmptyMixed;
use Psalm\Type\Atomic\TFalse;
Expand Down Expand Up @@ -104,6 +106,18 @@ public static function analyze(

$source = $statements_analyzer->getSource();

if ($lhs_type_part instanceof TCallableObject) {
self::handleCallableObject(
$statements_analyzer,
$stmt,
$context,
$lhs_type_part->callable,
$result,
$inferred_template_result,
);
return;
}

if (!$lhs_type_part instanceof TNamedObject) {
self::handleInvalidClass(
$statements_analyzer,
Expand Down Expand Up @@ -891,4 +905,55 @@ private static function handleRegularMixins(
$fq_class_name,
];
}

private static function handleCallableObject(
StatementsAnalyzer $statements_analyzer,
PhpParser\Node\Expr\MethodCall $stmt,
Context $context,
?TCallable $lhs_type_part_callable,
AtomicMethodCallAnalysisResult $result,
?TemplateResult $inferred_template_result = null
): void {
$method_id = 'object::__invoke';
$result->existent_method_ids[] = $method_id;
$result->has_valid_method_call_type = true;

if ($lhs_type_part_callable !== null) {
$result->return_type = $lhs_type_part_callable->return_type ?? Type::getMixed();
$callableArgumentCount = count($lhs_type_part_callable->params ?? []);
$providedArgumentsCount = count($stmt->getArgs());

if ($callableArgumentCount > $providedArgumentsCount) {
$result->too_few_arguments = true;
$result->too_few_arguments_method_ids[] = new MethodIdentifier('callable-object', '__invoke');
} elseif ($providedArgumentsCount > $callableArgumentCount) {
$result->too_many_arguments = true;
$result->too_many_arguments_method_ids[] = new MethodIdentifier('callable-object', '__invoke');
}

$template_result = $inferred_template_result ?? new TemplateResult([], []);

ArgumentsAnalyzer::analyze(
$statements_analyzer,
$stmt->getArgs(),
$lhs_type_part_callable->params,
$method_id,
false,
$context,
$template_result,
);

ArgumentsAnalyzer::checkArgumentsMatch(
$statements_analyzer,
$stmt->getArgs(),
$method_id,
$lhs_type_part_callable->params ?? [],
null,
null,
$template_result,
new CodeLocation($statements_analyzer->getSource(), $stmt),
$context,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
use Psalm\Type\Atomic\TString;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Atomic\TTemplateParamClass;
use Psalm\Type\Atomic\TUnknownClassString;
use Psalm\Type\TaintKind;
use Psalm\Type\Union;

Expand Down Expand Up @@ -777,9 +778,10 @@ private static function analyzeConstructorExpression(
) {
if (!$statements_analyzer->node_data->getType($stmt)) {
if ($lhs_type_part instanceof TClassString) {
$generated_type = $lhs_type_part->as_type
? $lhs_type_part->as_type
: new TObject();
$generated_type = $lhs_type_part->as_type ?? new TObject();
if ($lhs_type_part instanceof TUnknownClassString) {
$generated_type = $lhs_type_part->as_unknown_type ?? $generated_type;
}

if ($lhs_type_part->as_type
&& $codebase->classlikes->classExists($lhs_type_part->as_type->value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use Psalm\Storage\ClassLikeStorage;
use Psalm\Storage\Possibilities;
use Psalm\Type;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TObjectWithProperties;
use Psalm\Type\Atomic\TTemplateParam;
Expand Down Expand Up @@ -579,6 +580,7 @@ public static function getFunctionIdsFromCallableArg(
foreach ($type_part->extra_types as $extra_type) {
if ($extra_type instanceof TTemplateParam
|| $extra_type instanceof TObjectWithProperties
|| $extra_type instanceof TCallableObject
) {
throw new UnexpectedValueException('Shouldn’t get a generic param here');
}
Expand Down
11 changes: 8 additions & 3 deletions src/Psalm/Internal/Type/Comparator/ObjectComparator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Psalm\Codebase;
use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TIterable;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNamedObject;
Expand Down Expand Up @@ -90,6 +91,8 @@ public static function isShallowlyContainedBy(
$intersection_container_type_lower = 'object';
} elseif ($intersection_container_type instanceof TTemplateParam) {
$intersection_container_type_lower = null;
} elseif ($intersection_container_type instanceof TCallableObject) {
$intersection_container_type_lower = 'callable-object';
} else {
$container_was_static = $intersection_container_type->is_static;

Expand Down Expand Up @@ -134,7 +137,7 @@ public static function isShallowlyContainedBy(

/**
* @param TNamedObject|TTemplateParam|TIterable $type_part
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties>
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject>
*/
private static function getIntersectionTypes(Atomic $type_part): array
{
Expand Down Expand Up @@ -166,8 +169,8 @@ private static function getIntersectionTypes(Atomic $type_part): array
}

/**
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties $intersection_input_type
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties $intersection_container_type
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $intersection_input_type
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $intersection_container_type
*/
private static function isIntersectionShallowlyContainedBy(
Codebase $codebase,
Expand Down Expand Up @@ -268,6 +271,8 @@ private static function isIntersectionShallowlyContainedBy(
$intersection_input_type_lower = 'iterable';
} elseif ($intersection_input_type instanceof TObjectWithProperties) {
$intersection_input_type_lower = 'object';
} elseif ($intersection_input_type instanceof TCallableOBject) {
$intersection_input_type_lower = 'callable-object';
} else {
$input_was_static = $intersection_input_type->is_static;

Expand Down
3 changes: 2 additions & 1 deletion src/Psalm/Internal/Type/SimpleAssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -1603,7 +1603,8 @@ private static function reconcileObject(
if ($type->isObjectType()) {
$object_types[] = $type;
} elseif ($type instanceof TCallable) {
$object_types[] = new TCallableObject();
$callable_object = new TCallableObject($type->from_docblock, $type);
$object_types[] = $callable_object;
$redundant = false;
} elseif ($type instanceof TTemplateParam
&& $type->as->isMixed()
Expand Down
59 changes: 53 additions & 6 deletions src/Psalm/Internal/Type/TypeParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Psalm\Type\Atomic\TArrayKey;
use Psalm\Type\Atomic\TCallable;
use Psalm\Type\Atomic\TCallableKeyedArray;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TClassConstant;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TClassStringMap;
Expand Down Expand Up @@ -61,6 +62,7 @@
use Psalm\Type\Atomic\TTemplatePropertiesOf;
use Psalm\Type\Atomic\TTemplateValueOf;
use Psalm\Type\Atomic\TTypeAlias;
use Psalm\Type\Atomic\TUnknownClassString;
use Psalm\Type\Atomic\TValueOf;
use Psalm\Type\TypeNode;
use Psalm\Type\Union;
Expand Down Expand Up @@ -711,13 +713,25 @@ private static function getTypeFromGenericTree(

$types = [];
foreach ($generic_params[0]->getAtomicTypes() as $type) {
if (!$type instanceof TNamedObject) {
throw new TypeParseTreeException('Class string param should be a named object');
if ($type instanceof TNamedObject) {
$types[] = new TClassString($type->value, $type, false, false, false, $from_docblock);
continue;
}

$types[] = new TClassString($type->value, $type, false, false, false, $from_docblock);
if ($type instanceof TCallableObject) {
$types[] = new TUnknownClassString($type, false, $from_docblock);
continue;
}

throw new TypeParseTreeException('class-string param can only target to named or callable objects');
}

assert(
$types !== [],
'Since `Union` cannot be empty and all non-supported atomics lead to thrown exception,'
.' we can safely assert that the types array is non-empty.',
);

return new Union($types);
}

Expand Down Expand Up @@ -1200,15 +1214,29 @@ private static function getTypeFromIntersectionTree(
return $first_type;
}

$callable_intersection = null;

foreach ($intersection_types as $intersection_type) {
if ($intersection_type instanceof TIterable
|| $intersection_type instanceof TNamedObject
|| $intersection_type instanceof TTemplateParam
|| $intersection_type instanceof TObjectWithProperties
) {
$keyed_intersection_types[$intersection_type instanceof TIterable
? $intersection_type->getId()
: $intersection_type->getKey()] = $intersection_type;
$keyed_intersection_types[self::extractIntersectionKey($intersection_type)] = $intersection_type;
continue;
}

if (get_class($intersection_type) === TObject::class) {
continue;
}

if ($intersection_type instanceof TCallable) {
if ($callable_intersection !== null) {
throw new TypeParseTreeException(
'The intersection type must not contain more than one callable type!',
);
}
$callable_intersection = $intersection_type;
continue;
}

Expand All @@ -1218,6 +1246,15 @@ private static function getTypeFromIntersectionTree(
);
}

if ($callable_intersection !== null) {
$callable_object_type = new TCallableObject(
$callable_intersection->from_docblock,
$callable_intersection,
);

$keyed_intersection_types[self::extractIntersectionKey($callable_object_type)] = $callable_object_type;
}

$intersect_static = false;

if (isset($keyed_intersection_types['static'])) {
Expand Down Expand Up @@ -1531,4 +1568,14 @@ private static function getTypeFromKeyedArrayTree(
$from_docblock,
);
}

/**
* @param TNamedObject|TObjectWithProperties|TCallableObject|TIterable|TTemplateParam $intersection_type
*/
private static function extractIntersectionKey(Atomic $intersection_type): string
{
return $intersection_type instanceof TIterable
? $intersection_type->getId()
: $intersection_type->getKey();
}
}
14 changes: 7 additions & 7 deletions src/Psalm/Type/Atomic/HasIntersectionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
trait HasIntersectionTrait
{
/**
* @var array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties>
* @var array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject>
*/
public array $extra_types = [];

Expand All @@ -39,7 +39,7 @@ private function getNamespacedIntersectionTypes(
'&',
array_map(
/**
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties $extra_type
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $extra_type
*/
static fn(Atomic $extra_type): string => $extra_type->toNamespacedString(
$namespace,
Expand All @@ -53,7 +53,7 @@ private function getNamespacedIntersectionTypes(
}

/**
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties $type
* @param TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $type
* @return static
*/
public function addIntersectionType(Atomic $type): self
Expand All @@ -65,7 +65,7 @@ public function addIntersectionType(Atomic $type): self
}

/**
* @param array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties> $types
* @param array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject> $types
* @return static
*/
public function setIntersectionTypes(array $types): self
Expand All @@ -79,15 +79,15 @@ public function setIntersectionTypes(array $types): self
}

/**
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties>
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject>
*/
public function getIntersectionTypes(): array
{
return $this->extra_types;
}

/**
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties>|null
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject>|null
*/
protected function replaceIntersectionTemplateTypesWithArgTypes(
TemplateResult $template_result,
Expand Down Expand Up @@ -125,7 +125,7 @@ protected function replaceIntersectionTemplateTypesWithArgTypes(
}

/**
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties>|null
* @return array<string, TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject>|null
*/
protected function replaceIntersectionTemplateTypesWithStandins(
TemplateResult $template_result,
Expand Down
Loading

0 comments on commit ee68f16

Please sign in to comment.