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

Add @psalm-check-type and @psalm-check-type-exact.

I initially added these as part of my TryAnalyzer rewrite to allow testing complicated `finally` types like this:
```
$foo = 1;
try {
        $foo = 2;
} catch (Exception $_) {
        $foo = 3;
} finally {
        $bar = $foo;
        /** @psalm-check-type-exact $bar = 1|2|3 */;
}
/** @psalm-check-type-exact $bar = 2|3 */;
```
Using the `'assertions'` in tests doesn't work since the type is different inside the `finally`.

I decided to extract the new annotation from the rest of my changes and do a separate pull request since I think others may find it useful, and it should be much easier to review than the entire TryAnalyzer rewrite.
This commit is contained in:
AndrolGenhald 2022-02-17 10:26:28 -06:00
parent 1ee764894a
commit d09e420939
8 changed files with 203 additions and 1 deletions

View File

@ -193,6 +193,7 @@
<xs:element name="AmbiguousConstantInheritance" type="ClassConstantIssueHandlerType" minOccurs="0" />
<xs:element name="ArgumentTypeCoercion" type="ArgumentIssueHandlerType" minOccurs="0" />
<xs:element name="AssignmentToVoid" type="IssueHandlerType" minOccurs="0" />
<xs:element name="CheckType" type="IssueHandlerType" minOccurs="0" />
<xs:element name="CircularReference" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ComplexFunction" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ComplexMethod" type="IssueHandlerType" minOccurs="0" />

View File

@ -448,6 +448,31 @@ $username = $_GET['username']; // prints something like "test.php:4 $username: m
*Note*: it throws [special low-level issue](../running_psalm/issues/Trace.md), so you have to set errorLevel to 1, override it in config or invoke Psalm with `--show-info=true`.
### `@psalm-check-type`
You can use this annotation to ensure the inferred type matches what you expect.
```php
<?php
/** @psalm-check-type $foo = int */
$foo = 1; // No issue
/** @psalm-check-type $bar = int */
$bar = "not-an-int"; // Checked variable $bar = int does not match $bar = 'not-an-int'
```
### `@psalm-check-type-exact`
Like `@psalm-check-type`, but checks the exact type of the variable without allowing subtypes.
```php
<?php
/** @psalm-check-type-exact $foo = int */
$foo = 1; // Checked variable $foo = int does not match $foo = 1
```
### `@psalm-taint-*`
See [Security Analysis annotations](../security_analysis/annotations.md).

View File

@ -5,6 +5,7 @@
- [AmbiguousConstantInheritance](issues/AmbiguousConstantInheritance.md)
- [ArgumentTypeCoercion](issues/ArgumentTypeCoercion.md)
- [AssignmentToVoid](issues/AssignmentToVoid.md)
- [CheckType](issues/CheckType.md)
- [CircularReference](issues/CircularReference.md)
- [ComplexFunction](issues/ComplexFunction.md)
- [ComplexMethod](issues/ComplexMethod.md)

View File

@ -0,0 +1,19 @@
# CheckType
Checks if a variable matches a specific type.
Similar to [Trace](./Trace.md), but only shows if the type does not match the expected type.
```php
<?php
/** @psalm-check-type $x = 1 */
$x = 2; // Checked variable $x = 1 does not match $x = 2
```
```php
<?php
/** @psalm-check-type-exact $x = int */
$x = 2; // Checked variable $x = int does not match $x = 2
```

View File

@ -32,7 +32,7 @@ final 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', 'if-this-is', 'this-out'
'consistent-templates', 'if-this-is', 'this-out', 'check-type', 'check-type-exact',
];
/**

View File

@ -10,6 +10,7 @@ use Psalm\Context;
use Psalm\DocComment;
use Psalm\Exception\DocblockParseException;
use Psalm\Exception\IncorrectDocblockException;
use Psalm\Exception\TypeParseTreeException;
use Psalm\FileManipulation;
use Psalm\Internal\Analyzer\Statements\Block\DoAnalyzer;
use Psalm\Internal\Analyzer\Statements\Block\ForAnalyzer;
@ -42,6 +43,8 @@ use Psalm\Internal\FileManipulation\FileManipulationBuffer;
use Psalm\Internal\Provider\NodeDataProvider;
use Psalm\Internal\ReferenceConstraint;
use Psalm\Internal\Scanner\ParsedDocblock;
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
use Psalm\Issue\CheckType;
use Psalm\Issue\ComplexFunction;
use Psalm\Issue\ComplexMethod;
use Psalm\Issue\InvalidDocblock;
@ -64,9 +67,11 @@ use function array_change_key_case;
use function array_column;
use function array_combine;
use function array_keys;
use function array_map;
use function array_merge;
use function array_search;
use function count;
use function explode;
use function fwrite;
use function get_class;
use function in_array;
@ -370,6 +375,7 @@ class StatementsAnalyzer extends SourceAnalyzer
$new_issues = null;
$traced_variables = [];
$checked_types = [];
if ($docblock = $stmt->getDocComment()) {
$statements_analyzer->parseStatementDocblock($docblock, $stmt, $context);
@ -387,6 +393,13 @@ class StatementsAnalyzer extends SourceAnalyzer
}
}
foreach ($statements_analyzer->parsed_docblock->tags['psalm-check-type'] ?? [] as $inexact_check) {
$checked_types[] = [$inexact_check, false];
}
foreach ($statements_analyzer->parsed_docblock->tags['psalm-check-type-exact'] ?? [] as $exact_check) {
$checked_types[] = [$exact_check, true];
}
if (isset($statements_analyzer->parsed_docblock->tags['psalm-ignore-variable-method'])) {
$context->ignore_variable_method = $ignore_variable_method = true;
}
@ -660,6 +673,66 @@ class StatementsAnalyzer extends SourceAnalyzer
}
}
foreach ($checked_types as [$check_type_line, $is_exact]) {
/** @var string|null $check_type_string (incorrectly inferred) */
[$checked_var, $check_type_string] = array_map('trim', explode('=', $check_type_line));
if ($check_type_string === null) {
IssueBuffer::maybeAdd(
new InvalidDocblock(
"Invalid format for @psalm-check-type" . ($is_exact ? "-exact" : ""),
new CodeLocation($statements_analyzer->source, $stmt),
),
$statements_analyzer->getSuppressedIssues(),
);
} else {
$checked_var_id = $checked_var;
$possibly_undefined = strrpos($checked_var_id, "?") === strlen($checked_var_id) - 1;
if ($possibly_undefined) {
$checked_var_id = substr($checked_var_id, 0, strlen($checked_var_id) - 1);
}
if (!isset($context->vars_in_scope[$checked_var_id])) {
IssueBuffer::maybeAdd(
new InvalidDocblock(
"Attempt to check undefined variable $checked_var_id",
new CodeLocation($statements_analyzer->source, $stmt),
),
$statements_analyzer->getSuppressedIssues(),
);
} else {
try {
$checked_type = $context->vars_in_scope[$checked_var_id];
$check_type = Type::parseString($check_type_string);
$check_type->possibly_undefined = $possibly_undefined;
if ($check_type->possibly_undefined !== $checked_type->possibly_undefined
|| !UnionTypeComparator::isContainedBy($codebase, $checked_type, $check_type)
|| ($is_exact && !UnionTypeComparator::isContainedBy($codebase, $check_type, $checked_type))
) {
$check_var = $checked_var_id . ($checked_type->possibly_undefined ? "?" : "");
IssueBuffer::maybeAdd(
new CheckType(
"Checked variable $checked_var = {$check_type->getId()} does not match "
. "$check_var = {$checked_type->getId()}",
new CodeLocation($statements_analyzer->source, $stmt),
),
$statements_analyzer->getSuppressedIssues(),
);
}
} catch (TypeParseTreeException $e) {
IssueBuffer::maybeAdd(
new InvalidDocblock(
$e->getMessage(),
new CodeLocation($statements_analyzer->source, $stmt),
),
$statements_analyzer->getSuppressedIssues(),
);
}
}
}
}
return null;
}

View File

@ -0,0 +1,9 @@
<?php
namespace Psalm\Issue;
final class CheckType extends CodeIssue
{
public const ERROR_LEVEL = 8;
public const SHORTCODE = 311;
}

74
tests/CheckTypeTest.php Normal file
View File

@ -0,0 +1,74 @@
<?php
namespace Psalm\Tests;
use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait;
use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait;
class CheckTypeTest extends TestCase
{
use InvalidCodeAnalysisTestTrait;
use ValidCodeAnalysisTestTrait;
/**
* @return iterable<string,array{code:string,assertions?:array<string,string>,ignored_issues?:list<string>,php_version?:string}>
*/
public function providerValidCodeParse(): iterable
{
yield 'allowSubtype' => [
'code' => '<?php
/** @psalm-check-type $foo = int */
$foo = 1;
',
];
}
/**
* @return iterable<string,array{code:string,error_message:string,ignored_issues?:list<string>,php_version?:string}>
*/
public function providerInvalidCodeParse(): iterable
{
yield 'checkType' => [
'code' => '<?php
$foo = 1;
/** @psalm-check-type $foo = 2 */;
',
'error_message' => 'CheckType',
];
yield 'checkTypeExact' => [
'code' => '<?php
/** @psalm-check-type-exact $foo = int */
$foo = 1;
',
'error_message' => 'CheckType',
];
yield 'checkMultipleTypesFirstCorrect' => [
'code' => '<?php
$foo = 1;
$bar = 2;
/**
* @psalm-check-type $foo = 1
* @psalm-check-type $bar = 3
*/;
',
'error_message' => 'CheckType',
];
yield 'possiblyUnset' => [
'code' => '<?php
try {
$foo = 1;
} catch (Exception $_) {
}
/** @psalm-check-type $foo = 1 */;
',
'error_message' => 'Checked variable $foo = 1 does not match $foo? = 1',
];
yield 'notPossiblyUnset' => [
'code' => '<?php
$foo = 1;
/** @psalm-check-type $foo? = 1 */;
',
'error_message' => 'Checked variable $foo? = 1 does not match $foo = 1',
];
}
}