1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-21 21:31:13 +01:00

Merge pull request #6514 from zoonru/if-this-is

Add if-this-is
This commit is contained in:
orklah 2021-10-04 09:49:26 +02:00 committed by GitHub
commit 8b07e69645
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 348 additions and 3 deletions

View File

@ -237,6 +237,7 @@
<xs:element name="FalseOperand" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ForbiddenCode" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ForbiddenEcho" type="IssueHandlerType" minOccurs="0" />
<xs:element name="IfThisIsMismatch" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementationRequirementViolation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementedParamTypeMismatch" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementedReturnTypeMismatch" type="IssueHandlerType" minOccurs="0" />

View File

@ -1,9 +1,10 @@
# Adding assertions
Psalm has three docblock annotations that allow you to specify that a function verifies facts about variables and properties:
Psalm has four docblock annotations that allow you to specify that a function verifies facts about variables and properties:
- `@psalm-assert` (used when throwing an exception)
- `@psalm-assert-if-true`/`@psalm-assert-if-false` (used when returning a `bool`)
- `@psalm-if-this-is` (used when calling a method)
A list of acceptable assertions [can be found here](assertion_syntax.md).
@ -152,3 +153,39 @@ if( $result->hasException() ) {
Please note that the example above only works if you enable [method call memoization](https://psalm.dev/docs/running_psalm/configuration/#memoizemethodcallresults)
in the config file or annotate the class as [immutable](https://psalm.dev/docs/annotating_code/supported_annotations/#psalm-immutable).
You can also make sure, when calling a method, that its object has some specific template arguments:
```php
<?php
/**
* @template T
*/
class a {
/**
* @var T
*/
private $data;
/**
* @param T $data
*/
public function __construct($data) {
$this->data = $data;
}
/**
* @psalm-if-this-is a<int>
*/
public function test(): void {
}
}
$i = new a(123);
$i->test();
$i = new a("test");
// IfThisIsMismatch - Class is not a<int> as required by psalm-if-this-is
$i->test();
```

View File

@ -132,7 +132,7 @@ function addString(?string $s) {
`@psalm-suppress all` can be used to suppress all issues instead of listing them individually.
### `@psalm-assert`, `@psalm-assert-if-true` and `@psalm-assert-if-false`
### `@psalm-assert`, `@psalm-assert-if-true`, `@psalm-assert-if-false` and `@psalm-if-this-is`
See [Adding assertions](adding_assertions.md).

View File

@ -29,6 +29,7 @@
- [FalseOperand](issues/FalseOperand.md)
- [ForbiddenCode](issues/ForbiddenCode.md)
- [ForbiddenEcho](issues/ForbiddenEcho.md)
- [IfThisIsMismatch](issues/IfThisIsMismatch.md)
- [ImplementationRequirementViolation](issues/ImplementationRequirementViolation.md)
- [ImplementedParamTypeMismatch](issues/ImplementedParamTypeMismatch.md)
- [ImplementedReturnTypeMismatch](issues/ImplementedReturnTypeMismatch.md)

View File

@ -0,0 +1,35 @@
# IfThisIsMismatch
Emitted when the type in `@psalm-if-this-is` annotation cannot be contained by the object's actual type.
```php
<?php
/**
* @template T
*/
class a {
/**
* @var T
*/
private $data;
/**
* @param T $data
*/
public function __construct($data) {
$this->data = $data;
}
/**
* @psalm-if-this-is a<int>
*/
public function test(): void {
}
}
$i = new a(123);
$i->test();
$i = new a("test");
// IfThisIsMismatch - Class is not a<int> as required by psalm-if-this-is
$i->test();
```

View File

@ -39,7 +39,7 @@ class DocComment
'yield', 'trace', 'import-type', 'flow', 'taint-specialize', 'taint-escape',
'taint-unescape', 'self-out', 'consistent-constructor', 'stub-override',
'require-extends', 'require-implements', 'param-out', 'ignore-var',
'consistent-templates',
'consistent-templates', 'if-this-is',
];
/**

View File

@ -8,6 +8,8 @@ use Psalm\Internal\Analyzer\Statements\Expression\ExpressionIdentifier;
use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\MethodIdentifier;
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
use Psalm\Issue\IfThisIsMismatch;
use Psalm\Issue\InvalidMethodCall;
use Psalm\Issue\InvalidScope;
use Psalm\Issue\NullReference;
@ -434,6 +436,24 @@ class MethodCallAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\
// TODO: When should a method have a storage?
if ($codebase->methods->hasStorage($method_id)) {
$storage = $codebase->methods->getStorage($method_id);
if ($storage->if_this_is_type
&& !UnionTypeComparator::isContainedBy(
$codebase,
$class_type,
$storage->if_this_is_type
)
) {
if (IssueBuffer::accepts(
new IfThisIsMismatch(
'Class is not ' . (string) $storage->if_this_is_type
. ' as required by psalm-if-this-is',
new CodeLocation($source, $stmt->name)
),
$statements_analyzer->getSuppressedIssues()
)) {
// keep going
}
}
if ($storage->self_out_type) {
$self_out_type = $storage->self_out_type;
$context->vars_in_scope[$lhs_var_id] = $self_out_type;

View File

@ -182,6 +182,24 @@ class FunctionLikeDocblockParser
}
}
if (isset($parsed_docblock->tags['psalm-if-this-is'])) {
foreach ($parsed_docblock->tags['psalm-if-this-is'] as $offset => $param) {
$line_parts = CommentAnalyzer::splitDocLine($param);
$line_parts[0] = str_replace("\n", '', preg_replace('@^[ \t]*\*@m', '', $line_parts[0]));
$info->if_this_is = [
'type' => str_replace("\n", '', $line_parts[0]),
'line_number' => $comment->getStartLine() + substr_count(
$comment->getText(),
"\n",
0,
$offset - $comment->getStartFilePos()
),
];
}
}
if (isset($parsed_docblock->tags['psalm-taint-sink'])) {
foreach ($parsed_docblock->tags['psalm-taint-sink'] as $param) {
$param_parts = preg_split('/\s+/', trim($param));

View File

@ -283,6 +283,22 @@ class FunctionLikeDocblockScanner
$storage->self_out_type = $out_type;
}
if ($docblock_info->if_this_is
&& $storage instanceof MethodStorage) {
$out_type = TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$docblock_info->if_this_is['type'],
$aliases,
$function_template_types + $class_template_types,
$type_aliases
),
null,
$function_template_types + $class_template_types,
$type_aliases
);
$storage->if_this_is_type = $out_type;
}
foreach ($docblock_info->taint_sink_params as $taint_sink_param) {
$param_name = substr($taint_sink_param['name'], 1);

View File

@ -56,6 +56,11 @@ class FunctionDocblockComment
*/
public $self_out;
/**
* @var array{type:string, line_number: int}|null
*/
public $if_this_is;
/**
* @var array<int, array{name:string, type:string, line_number: int}>
*/

View File

@ -0,0 +1,8 @@
<?php
namespace Psalm\Issue;
class IfThisIsMismatch extends CodeIssue
{
const ERROR_LEVEL = 4;
const SHORTCODE = 300;
}

View File

@ -90,6 +90,10 @@ class MethodStorage extends FunctionLikeStorage
*/
public $self_out_type;
/**
* @var Type\Union|null
*/
public $if_this_is_type = null;
/**
* @var bool
*/

200
tests/IfThisIsTest.php Normal file
View File

@ -0,0 +1,200 @@
<?php
namespace Psalm\Tests;
class IfThisIsTest extends TestCase
{
use Traits\ValidCodeAnalysisTestTrait;
use Traits\InvalidCodeAnalysisTestTrait;
/**
* @return iterable<string,array{string,assertions?:array<string,string>,error_levels?:string[]}>
*/
public function providerValidCodeParse(): iterable
{
return [
'worksAfterConvert' => [
'<?php
interface I {
/**
* @return void
*/
public function test();
}
class F implements I
{
/**
* @psalm-self-out I
* @return void
*/
public function convert() {}
/**
* @psalm-if-this-is I
* @return void
*/
public function test() {}
}
$f = new F();
$f->convert();
$f->test();
'
],
'withTemplate' => [
'<?php
class Frozen {}
class Unfrozen {}
/**
* @template T of Frozen|Unfrozen
*/
class Foo
{
/**
* @var T
*/
private $state;
/**
* @param T $state
*/
public function __construct($state)
{
$this->state = $state;
}
/**
* @param string $name
* @param mixed $val
* @psalm-if-this-is Foo<Unfrozen>
* @return void
*/
public function set($name, $val)
{
}
/**
* @return Foo<Frozen>
*/
public function freeze()
{
/** @var Foo<Frozen> */
$f = clone $this;
return $f;
}
}
$f = new Foo(new Unfrozen());
$f->set("asd", 10);
'
],
'subclass' => [
'<?php
class G
{
/**
* @psalm-if-this-is G
* @return void
*/
public function test() {}
}
class F extends G
{
}
$f = new F();
$f->test();
'
]
];
}
/**
* @return array<string, array{0: string, error_message: string}>
*/
public function providerInvalidCodeParse(): iterable
{
return [
'failsWithWrongTemplate1' => [
'<?php
/**
* @template T
*/
class a {
/**
* @var T
*/
private $data;
/**
* @param T $data
*/
public function __construct($data) {
$this->data = $data;
}
/**
* @psalm-if-this-is a<int>
*/
public function test(): void {
}
}
$i = new a("test");
$i->test();
',
'error_message' => 'IfThisIsMismatch'
],
'failsWithWrongTemplate2' => [
'<?php
class Frozen {}
class Unfrozen {}
/**
* @template T of Frozen|Unfrozen
*/
class Foo
{
/**
* @var T
*/
private $state;
/**
* @param T $state
*/
public function __construct($state)
{
$this->state = $state;
}
/**
* @param string $name
* @param mixed $val
* @psalm-if-this-is Foo<Unfrozen>
* @return void
*/
public function set($name, $val) {}
/**
* @return Foo<Frozen>
*/
public function freeze()
{
/** @var Foo<Frozen> */
$f = clone $this;
return $f;
}
}
$f = new Foo(new Unfrozen());
$f->set("asd", 10);
$g = $f->freeze();
$g->set("asd", 20); // Fails
',
'error_message' => 'IfThisIsMismatch'
],
];
}
}