Skip to content

Commit

Permalink
Merge pull request #7686 from AndrolGenhald/feature/type-check-annota…
Browse files Browse the repository at this point in the history
…tions

Add `@psalm-check-type` and `@psalm-check-type-exact`.
  • Loading branch information
orklah committed Feb 17, 2022
2 parents 1ee7648 + d09e420 commit ec95244
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 1 deletion.
1 change: 1 addition & 0 deletions config.xsd
Expand Up @@ -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" />
Expand Down
25 changes: 25 additions & 0 deletions docs/annotating_code/supported_annotations.md
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions docs/running_psalm/issues.md
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions docs/running_psalm/issues/CheckType.md
@@ -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
```
2 changes: 1 addition & 1 deletion src/Psalm/DocComment.php
Expand Up @@ -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',
];

/**
Expand Down
73 changes: 73 additions & 0 deletions src/Psalm/Internal/Analyzer/StatementsAnalyzer.php
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -42,6 +43,8 @@
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;
Expand All @@ -64,9 +67,11 @@
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;
Expand Down Expand Up @@ -370,6 +375,7 @@ private static function analyzeStatement(
$new_issues = null;
$traced_variables = [];

$checked_types = [];
if ($docblock = $stmt->getDocComment()) {
$statements_analyzer->parseStatementDocblock($docblock, $stmt, $context);

Expand All @@ -387,6 +393,13 @@ private static function analyzeStatement(
}
}

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;
}
Expand Down Expand Up @@ -660,6 +673,66 @@ private static function analyzeStatement(
}
}

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;
}

Expand Down
9 changes: 9 additions & 0 deletions src/Psalm/Issue/CheckType.php
@@ -0,0 +1,9 @@
<?php

namespace Psalm\Issue;

final class CheckType extends CodeIssue
{
public const ERROR_LEVEL = 8;
public const SHORTCODE = 311;
}
74 changes: 74 additions & 0 deletions tests/CheckTypeTest.php
@@ -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',
];
}
}

0 comments on commit ec95244

Please sign in to comment.