Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from Feb 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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',
];
}
}