diff --git a/config.xsd b/config.xsd index ff3dd6a15dd..3fe1e510b43 100644 --- a/config.xsd +++ b/config.xsd @@ -193,6 +193,7 @@ + diff --git a/docs/annotating_code/supported_annotations.md b/docs/annotating_code/supported_annotations.md index e1066f04de7..996b88e9c66 100644 --- a/docs/annotating_code/supported_annotations.md +++ b/docs/annotating_code/supported_annotations.md @@ -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 +getDocComment()) { $statements_analyzer->parseStatementDocblock($docblock, $stmt, $context); @@ -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; } @@ -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; } diff --git a/src/Psalm/Issue/CheckType.php b/src/Psalm/Issue/CheckType.php new file mode 100644 index 00000000000..a101ad1f256 --- /dev/null +++ b/src/Psalm/Issue/CheckType.php @@ -0,0 +1,9 @@ +,ignored_issues?:list,php_version?:string}> + */ + public function providerValidCodeParse(): iterable + { + yield 'allowSubtype' => [ + 'code' => ',php_version?:string}> + */ + public function providerInvalidCodeParse(): iterable + { + yield 'checkType' => [ + 'code' => ' 'CheckType', + ]; + yield 'checkTypeExact' => [ + 'code' => ' 'CheckType', + ]; + yield 'checkMultipleTypesFirstCorrect' => [ + 'code' => ' 'CheckType', + ]; + yield 'possiblyUnset' => [ + 'code' => ' 'Checked variable $foo = 1 does not match $foo? = 1', + ]; + yield 'notPossiblyUnset' => [ + 'code' => ' 'Checked variable $foo? = 1 does not match $foo = 1', + ]; + } +}