diff --git a/bin/phar.psalm.xml b/bin/phar.psalm.xml index 01098a2d8..7fd4c4886 100644 --- a/bin/phar.psalm.xml +++ b/bin/phar.psalm.xml @@ -2,7 +2,7 @@ - + + + + Setting `totallyTyped` to `"true"` is equivalent to setting `errorLevel` to `"1"`. Setting `totallyTyped` to `"false"` is equivalent to setting `errorLevel` to `"2"` and `reportMixedIssues` to `"false"` + + + + + Deprecated. Replaced by `errorLevel` and `reportMixedIssues`. + + + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 5c8237d38..3f67d8fb0 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -2535,7 +2535,7 @@ return [ 'error_log' => ['bool', 'message'=>'string', 'message_type='=>'int', 'destination='=>'string', 'additional_headers='=>'string'], 'error_reporting' => ['int', 'error_level='=>'int'], 'ErrorException::__clone' => ['void'], -'ErrorException::__construct' => ['void', 'message='=>'string', 'code='=>'int', 'severity='=>'int', 'filename='=>'string', 'lineno='=>'int', 'previous='=>'?Throwable|?ErrorException'], +'ErrorException::__construct' => ['void', 'message='=>'string', 'code='=>'int', 'severity='=>'int', 'filename='=>'string', 'line='=>'int', 'previous='=>'?Throwable|?ErrorException'], 'ErrorException::__toString' => ['string'], 'ErrorException::getCode' => ['int'], 'ErrorException::getFile' => ['string'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 8ca16b883..206ea13ec 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -1382,7 +1382,7 @@ return [ 'Error::getTrace' => ['list>'], 'Error::getTraceAsString' => ['string'], 'ErrorException::__clone' => ['void'], - 'ErrorException::__construct' => ['void', 'message='=>'string', 'code='=>'int', 'severity='=>'int', 'filename='=>'string', 'lineno='=>'int', 'previous='=>'?Throwable|?ErrorException'], + 'ErrorException::__construct' => ['void', 'message='=>'string', 'code='=>'int', 'severity='=>'int', 'filename='=>'string', 'line='=>'int', 'previous='=>'?Throwable|?ErrorException'], 'ErrorException::__toString' => ['string'], 'ErrorException::getCode' => ['int'], 'ErrorException::getFile' => ['string'], diff --git a/dictionaries/PropertyMap.php b/dictionaries/PropertyMap.php index e0f91665d..e5d326244 100644 --- a/dictionaries/PropertyMap.php +++ b/dictionaries/PropertyMap.php @@ -256,7 +256,7 @@ return [ 'client_info' => 'string', 'client_version' => 'int', 'connect_errno' => 'int', - 'connect_error' => 'string', + 'connect_error' => '?string', 'errno' => 'int', 'error' => 'string', 'error_list' => 'array', diff --git a/docs/running_psalm/configuration.md b/docs/running_psalm/configuration.md index b94c84805..f8735e390 100644 --- a/docs/running_psalm/configuration.md +++ b/docs/running_psalm/configuration.md @@ -416,6 +416,13 @@ Whether or not to allow `require`/`include` calls in your PHP. Defaults to `true ``` Allows you to hard-code a serializer for Psalm to use when caching data. By default, Psalm uses `ext-igbinary` *if* the version is greater than or equal to 2.0.5, otherwise it defaults to PHP's built-in serializer. +#### threads +```xml + +``` +Allows you to hard-code the number of threads Psalm will use (similar to `--threads` on the command line). This value will be used in place of detecting threads from the host machine, but will be overridden by using `--threads` or `--debug` (which sets threads to 1) on the command line ## Project settings diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 790de749a..ac4355b55 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -584,6 +584,9 @@ class Config */ public $internal_stubs = []; + /** @var ?int */ + public $threads; + protected function __construct() { self::$instance = $this; @@ -811,7 +814,9 @@ class Config $deprecated_attributes = [ 'allowCoercionFromStringToClassConst', 'allowPhpStormGenerics', - 'forbidEcho' + 'forbidEcho', + 'loadXdebugStub', + 'totallyTyped' ]; $deprecated_elements = [ @@ -1241,6 +1246,10 @@ class Config } } + if (isset($config_xml->threads)) { + $config->threads = (int)$config_xml->threads; + } + return $config; } @@ -1781,11 +1790,22 @@ class Config public function getReportingLevelForFunction(string $issue_type, string $function_id): ?string { + $level = null; if (isset($this->issue_handlers[$issue_type])) { - return $this->issue_handlers[$issue_type]->getReportingLevelForFunction($function_id); + $level = $this->issue_handlers[$issue_type]->getReportingLevelForFunction($function_id); + + if ($level === null && $issue_type === 'UndefinedFunction') { + // undefined functions trigger global namespace fallback + // so we should also check reporting levels for the symbol in global scope + $root_function_id = preg_replace('/.*\\\/', '', $function_id); + if ($root_function_id !== $function_id) { + /** @psalm-suppress PossiblyUndefinedStringArrayOffset https://github.com/vimeo/psalm/issues/7656 */ + $level = $this->issue_handlers[$issue_type]->getReportingLevelForFunction($root_function_id); + } + } } - return null; + return $level; } public function getReportingLevelForArgument(string $issue_type, string $function_id): ?string @@ -2009,6 +2029,12 @@ class Config $this->internal_stubs[] = $ext_decimal_path; } + // phpredis + if (extension_loaded('redis')) { + $ext_phpredis_path = $dir_lvl_2 . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . 'phpredis.phpstub'; + $this->internal_stubs[] = $ext_phpredis_path; + } + foreach ($this->internal_stubs as $stub_path) { if (!file_exists($stub_path)) { throw new UnexpectedValueException('Cannot locate ' . $stub_path); diff --git a/src/Psalm/Context.php b/src/Psalm/Context.php index 3b9470d6f..73e642af9 100644 --- a/src/Psalm/Context.php +++ b/src/Psalm/Context.php @@ -108,6 +108,13 @@ class Context */ public $inside_assignment = false; + /** + * Whether or not we're inside a try block. + * + * @var bool + */ + public $inside_try = false; + /** * @var null|CodeLocation */ diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/TryAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/TryAnalyzer.php index 8ecffb85d..5074bd68a 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/TryAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/TryAnalyzer.php @@ -86,9 +86,12 @@ class TryAnalyzer $old_referenced_var_ids = $try_context->referenced_var_ids; + $was_inside_try = $context->inside_try; + $context->inside_try = true; if ($statements_analyzer->analyze($stmt->stmts, $context) === false) { return false; } + $context->inside_try = $was_inside_try; if ($try_context->finally_scope) { foreach ($context->vars_in_scope as $var_id => $type) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php index 407c6d044..50ccf068d 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php @@ -315,6 +315,14 @@ class AssignmentAnalyzer $assign_value_type->parent_nodes = [ $assignment_node->id => $assignment_node ]; + + if ($context->inside_try) { + // Copy previous assignment's parent nodes inside a try. Since an exception could be thrown at any + // point this is a workaround to ensure that use of a variable also uses all previous assignments. + if (isset($context->vars_in_scope[$array_var_id])) { + $assign_value_type->parent_nodes += $context->vars_in_scope[$array_var_id]->parent_nodes; + } + } } if ($array_var_id && isset($context->vars_in_scope[$array_var_id])) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/MagicConstAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/MagicConstAnalyzer.php index 4432087d9..481a5afa0 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/MagicConstAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/MagicConstAnalyzer.php @@ -16,6 +16,8 @@ use Psalm\Type\Atomic\TCallableString; use Psalm\Type\Atomic\TNonEmptyString; use Psalm\Type\Union; +use function dirname; + class MagicConstAnalyzer { public static function analyze( @@ -84,10 +86,16 @@ class MagicConstAnalyzer } else { $statements_analyzer->node_data->setType($stmt, new Union([new TCallableString])); } - } elseif ($stmt instanceof PhpParser\Node\Scalar\MagicConst\File - || $stmt instanceof PhpParser\Node\Scalar\MagicConst\Dir - ) { - $statements_analyzer->node_data->setType($stmt, new Union([new TNonEmptyString()])); + } elseif ($stmt instanceof PhpParser\Node\Scalar\MagicConst\Dir) { + $statements_analyzer->node_data->setType( + $stmt, + Type::getString(dirname($statements_analyzer->getSource()->getFilePath())) + ); + } elseif ($stmt instanceof PhpParser\Node\Scalar\MagicConst\File) { + $statements_analyzer->node_data->setType( + $stmt, + Type::getString($statements_analyzer->getSource()->getFilePath()) + ); } elseif ($stmt instanceof PhpParser\Node\Scalar\MagicConst\Trait_) { if ($statements_analyzer->getSource() instanceof TraitAnalyzer) { $statements_analyzer->node_data->setType($stmt, new Union([new TNonEmptyString()])); diff --git a/src/Psalm/Internal/Cli/Psalm.php b/src/Psalm/Internal/Cli/Psalm.php index 36043f4cf..0f41c9974 100644 --- a/src/Psalm/Internal/Cli/Psalm.php +++ b/src/Psalm/Internal/Cli/Psalm.php @@ -246,7 +246,7 @@ final class Psalm $options['long-progress'] = true; } - $threads = self::detectThreads($options, $in_ci); + $threads = self::detectThreads($options, $config, $in_ci); self::emitMacPcreWarning($options, $threads); @@ -909,12 +909,14 @@ final class Psalm } } - private static function detectThreads(array $options, bool $in_ci): int + private static function detectThreads(array $options, Config $config, bool $in_ci): int { if (isset($options['threads'])) { $threads = (int)$options['threads']; } elseif (isset($options['debug']) || $in_ci) { $threads = 1; + } elseif ($config->threads) { + $threads = $config->threads; } else { $threads = max(1, ProjectAnalyzer::getCpuCount() - 1); } diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php index 9de676135..5c666574f 100644 --- a/src/Psalm/IssueBuffer.php +++ b/src/Psalm/IssueBuffer.php @@ -691,7 +691,7 @@ class IssueBuffer : $error_count . ' errors' ) . ' found' . "\n"; } else { - self::printSuccessMessage(); + self::printSuccessMessage($project_analyzer); } $show_info = $project_analyzer->stdout_report_options->show_info; @@ -782,8 +782,12 @@ class IssueBuffer } } - public static function printSuccessMessage(): void + public static function printSuccessMessage(ProjectAnalyzer $project_analyzer): void { + if (!$project_analyzer->stdout_report_options) { + throw new UnexpectedValueException('Cannot print success message without stdout report options'); + } + // this message will be printed $message = "No errors found!"; @@ -808,9 +812,15 @@ class IssueBuffer // text style, 1 = bold $style = "1"; - echo "\e[{$background};{$style}m{$paddingTop}\e[0m" . "\n"; - echo "\e[{$background};{$foreground};{$style}m{$messageWithPadding}\e[0m" . "\n"; - echo "\e[{$background};{$style}m{$paddingBottom}\e[0m" . "\n"; + if ($project_analyzer->stdout_report_options->use_color) { + echo "\e[{$background};{$style}m{$paddingTop}\e[0m" . "\n"; + echo "\e[{$background};{$foreground};{$style}m{$messageWithPadding}\e[0m" . "\n"; + echo "\e[{$background};{$style}m{$paddingBottom}\e[0m" . "\n"; + } else { + echo "\n"; + echo "$messageWithPadding\n"; + echo "\n"; + } } /** diff --git a/stubs/Php81.phpstub b/stubs/Php81.phpstub index 97dd281e1..d3398388d 100644 --- a/stubs/Php81.phpstub +++ b/stubs/Php81.phpstub @@ -56,6 +56,13 @@ namespace { */ public function getBackingValue(): int|string; } + + class ReflectionIntersectionType extends ReflectionType { + /** + * @return non-empty-list + */ + public function getTypes() {} + } } namespace FTP { diff --git a/stubs/phpredis.phpstub b/stubs/phpredis.phpstub new file mode 100644 index 000000000..65dd5f7c2 --- /dev/null +++ b/stubs/phpredis.phpstub @@ -0,0 +1,542 @@ + - + @@ -185,9 +183,7 @@ class ConfigFileTest extends TestCase $abcEnabled = trim(' - + diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php index 6a15f41c9..867099ff7 100644 --- a/tests/Config/ConfigTest.php +++ b/tests/Config/ConfigTest.php @@ -355,6 +355,36 @@ class ConfigTest extends TestCase $this->assertFalse($config->reportIssueInFile('MissingReturnType', realpath('src/Psalm/Type.php'))); } + public function testGlobalUndefinedFunctionSuppression(): void + { + $this->project_analyzer = $this->getProjectAnalyzerWithConfig( + Config::loadFromXML( + dirname(__DIR__, 2), + ' + + + + + + + + + + + + + + ' + ) + ); + + $config = $this->project_analyzer->getConfig(); + $this->assertSame( + Config::REPORT_SUPPRESS, + $config->getReportingLevelForFunction('UndefinedFunction', 'Some\Namespace\zzz') + ); + } + public function testMultipleIssueHandlers(): void { $this->project_analyzer = $this->getProjectAnalyzerWithConfig( diff --git a/tests/IssueBufferTest.php b/tests/IssueBufferTest.php index a1f871477..7da31f16a 100644 --- a/tests/IssueBufferTest.php +++ b/tests/IssueBufferTest.php @@ -113,8 +113,10 @@ class IssueBufferTest extends TestCase public function testPrintSuccessMessageWorks(): void { + $project_analyzer = $this->createMock(ProjectAnalyzer::class); + $project_analyzer->stdout_report_options = new ReportOptions; ob_start(); - IssueBuffer::printSuccessMessage(); + IssueBuffer::printSuccessMessage($project_analyzer); $output = ob_get_clean(); $this->assertStringContainsString('No errors found!', $output); diff --git a/tests/TaintTest.php b/tests/TaintTest.php index 75419f8a6..302ca2434 100644 --- a/tests/TaintTest.php +++ b/tests/TaintTest.php @@ -2285,6 +2285,25 @@ class TaintTest extends TestCase echo sinkNotWorking($_GET["taint"]);', 'error_message' => 'TaintedHtml', ], + 'taintEscapedInTryMightNotWork' => [ + ' 'TaintedHtml', + ], ]; } diff --git a/tests/UnusedVariableTest.php b/tests/UnusedVariableTest.php index 61fdb840f..048025186 100644 --- a/tests/UnusedVariableTest.php +++ b/tests/UnusedVariableTest.php @@ -2436,6 +2436,43 @@ class UnusedVariableTest extends TestCase $a = false; }' ], + 'usedInCatchIsAlwaysUsedInTry' => [ + ' [ + ' [ + ' 'MixedArgumentTypeCoercion - src' . DIRECTORY_SEPARATOR . 'somefile.php:12:44 - Argument 1 of takesArrayOfString expects array, parent type array{mixed} provided. Consider improving the type at src' . DIRECTORY_SEPARATOR . 'somefile.php:10:41' ], + 'warnAboutUnusedVariableInTryReassignedInCatch' => [ + ' 'UnusedVariable', + ], + 'warnAboutUnusedVariableInTryReassignedInFinally' => [ + ' 'UnusedVariable', + ], + 'SKIPPED-warnAboutVariableUsedInNestedTryNotUsedInOuterTry' => [ + ' 'UnusedVariable', + ], ]; } } diff --git a/tests/fixtures/DestructiveAutoloader/psalm.xml b/tests/fixtures/DestructiveAutoloader/psalm.xml index ec68f8f2e..3a4cc64a8 100644 --- a/tests/fixtures/DestructiveAutoloader/psalm.xml +++ b/tests/fixtures/DestructiveAutoloader/psalm.xml @@ -1,6 +1,5 @@