1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-26 20:34:47 +01:00

Merge remote-tracking branch 'upstream/4.x' into upstream-master

This commit is contained in:
orklah 2022-01-14 21:01:25 +01:00
commit b633619a2c
33 changed files with 1008 additions and 431 deletions

View File

@ -0,0 +1,25 @@
#!/usr/bin/env php
<?php
$docs_dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . "docs"
. DIRECTORY_SEPARATOR . "running_psalm" . DIRECTORY_SEPARATOR;
$issues_index = "{$docs_dir}issues.md";
$issues_dir = "{$docs_dir}issues";
if (!file_exists($issues_dir)) {
throw new UnexpectedValueException("Issues documentation not found");
}
$issues_list = array_filter(array_map(function (string $issue_file) {
if ($issue_file === "." || $issue_file === ".." || substr($issue_file, -3) !== ".md") {
return false;
}
$issue = substr($issue_file, 0, strlen($issue_file) - 3);
return " - [$issue](issues/$issue.md)";
}, scandir($issues_dir)));
usort($issues_list, "strcasecmp");
$issues_md_contents = array_merge(["# Issue types", ""], $issues_list, [""]);
file_put_contents($issues_index, implode("\n", $issues_md_contents));

View File

@ -41,7 +41,13 @@
<xs:attribute name="findUnusedCode" type="xs:boolean" default="false" />
<xs:attribute name="findUnusedVariablesAndParams" type="xs:boolean" default="false" />
<xs:attribute name="findUnusedPsalmSuppress" type="xs:boolean" default="false" />
<xs:attribute name="forbidEcho" type="xs:boolean" default="false" />
<xs:attribute name="forbidEcho" type="xs:boolean" default="false">
<xs:annotation>
<xs:documentation xml:lang="en">
Deprecated. Will be replaced by adding echo to forbiddenFunctions in Psalm 5.
</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="hideExternalErrors" type="xs:boolean" default="false" />
<xs:attribute name="hoistConstants" type="xs:boolean" default="false" />
<xs:attribute name="ignoreInternalFunctionFalseReturn" type="xs:boolean" default="true" />
@ -69,6 +75,7 @@
<xs:attribute name="usePhpDocPropertiesWithoutMagicCall" type="xs:boolean" default="false" />
<xs:attribute name="skipChecksOnUnresolvableIncludes" type="xs:boolean" default="false" />
<xs:attribute name="sealAllMethods" type="xs:boolean" default="false" />
<xs:attribute name="sealAllProperties" type="xs:boolean" default="false" />
<xs:attribute name="runTaintAnalysis" type="xs:boolean" default="false" />
<xs:attribute name="usePhpStormMetaPath" type="xs:boolean" default="true" />
<xs:attribute name="allowInternalNamedArgumentCalls" type="xs:boolean" default="true" />

View File

@ -1996,7 +1996,7 @@ return [
'dio_tcsetattr' => ['bool', 'fd'=>'resource', 'options'=>'array'],
'dio_truncate' => ['bool', 'fd'=>'resource', 'offset'=>'int'],
'dio_write' => ['int', 'fd'=>'resource', 'data'=>'string', 'length='=>'int'],
'dir' => ['Directory|false|null', 'directory'=>'string', 'context='=>'resource'],
'dir' => ['Directory|false', 'directory'=>'string', 'context='=>'resource'],
'Directory::close' => ['void', 'dir_handle='=>'resource'],
'Directory::read' => ['string|false', 'dir_handle='=>'resource'],
'Directory::rewind' => ['void', 'dir_handle='=>'resource'],

View File

@ -10375,7 +10375,7 @@ return [
'dio_tcsetattr' => ['bool', 'fd'=>'resource', 'options'=>'array'],
'dio_truncate' => ['bool', 'fd'=>'resource', 'offset'=>'int'],
'dio_write' => ['int', 'fd'=>'resource', 'data'=>'string', 'length='=>'int'],
'dir' => ['Directory|false|null', 'directory'=>'string', 'context='=>'resource'],
'dir' => ['Directory|false', 'directory'=>'string', 'context='=>'resource'],
'dirname' => ['string', 'path'=>'string', 'levels='=>'int'],
'disk_free_space' => ['float|false', 'directory'=>'string'],
'disk_total_space' => ['float|false', 'directory'=>'string'],

View File

@ -294,6 +294,16 @@ This defaults to `false`.
When `true`, Psalm will treat all classes as if they had sealed methods, meaning that if you implement the magic method `__call`, you also have to add `@method` for each magic method. Defaults to false.
#### sealAllProperties
```xml
<psalm
sealAllProperties="[bool]"
>
```
When `true`, Psalm will treat all classes as if they had sealed properties, meaning that Psalm will disallow getting and setting any properties not contained in a list of `@property` (or `@property-read`/`@property-write`) annotations and not explicitly defined as a `property`. Defaults to false.
#### runTaintAnalysis
```xml

View File

@ -7,6 +7,7 @@
- [CircularReference](issues/CircularReference.md)
- [ComplexFunction](issues/ComplexFunction.md)
- [ComplexMethod](issues/ComplexMethod.md)
- [ConfigIssue](issues/ConfigIssue.md)
- [ConflictingReferenceConstraint](issues/ConflictingReferenceConstraint.md)
- [ConstructorSignatureMismatch](issues/ConstructorSignatureMismatch.md)
- [ContinueOutsideLoop](issues/ContinueOutsideLoop.md)
@ -21,6 +22,8 @@
- [DuplicateArrayKey](issues/DuplicateArrayKey.md)
- [DuplicateClass](issues/DuplicateClass.md)
- [DuplicateConstant](issues/DuplicateConstant.md)
- [DuplicateEnumCase](issues/DuplicateEnumCase.md)
- [DuplicateEnumCaseValue](issues/DuplicateEnumCaseValue.md)
- [DuplicateFunction](issues/DuplicateFunction.md)
- [DuplicateMethod](issues/DuplicateMethod.md)
- [DuplicateParam](issues/DuplicateParam.md)
@ -62,6 +65,7 @@
- [InvalidDocblock](issues/InvalidDocblock.md)
- [InvalidDocblockParamName](issues/InvalidDocblockParamName.md)
- [InvalidEnumBackingType](issues/InvalidEnumBackingType.md)
- [InvalidEnumCaseValue](issues/InvalidEnumCaseValue.md)
- [InvalidExtendClass](issues/InvalidExtendClass.md)
- [InvalidFalsableReturnType](issues/InvalidFalsableReturnType.md)
- [InvalidFunctionCall](issues/InvalidFunctionCall.md)
@ -132,6 +136,7 @@
- [MoreSpecificReturnType](issues/MoreSpecificReturnType.md)
- [MutableDependency](issues/MutableDependency.md)
- [NamedArgumentNotAllowed](issues/NamedArgumentNotAllowed.md)
- [NoEnumProperties](issues/NoEnumProperties.md)
- [NoInterfaceProperties](issues/NoInterfaceProperties.md)
- [NonInvariantDocblockPropertyType](issues/NonInvariantDocblockPropertyType.md)
- [NonInvariantPropertyType](issues/NonInvariantPropertyType.md)
@ -166,6 +171,7 @@
- [PossiblyInvalidArrayOffset](issues/PossiblyInvalidArrayOffset.md)
- [PossiblyInvalidCast](issues/PossiblyInvalidCast.md)
- [PossiblyInvalidClone](issues/PossiblyInvalidClone.md)
- [PossiblyInvalidDocblockTag](issues/PossiblyInvalidDocblockTag.md)
- [PossiblyInvalidFunctionCall](issues/PossiblyInvalidFunctionCall.md)
- [PossiblyInvalidIterator](issues/PossiblyInvalidIterator.md)
- [PossiblyInvalidMethodCall](issues/PossiblyInvalidMethodCall.md)
@ -193,6 +199,7 @@
- [PossiblyUnusedMethod](issues/PossiblyUnusedMethod.md)
- [PossiblyUnusedParam](issues/PossiblyUnusedParam.md)
- [PossiblyUnusedProperty](issues/PossiblyUnusedProperty.md)
- [PossiblyUnusedReturnValue](issues/PossiblyUnusedReturnValue.md)
- [PropertyNotSetInConstructor](issues/PropertyNotSetInConstructor.md)
- [PropertyTypeCoercion](issues/PropertyTypeCoercion.md)
- [RawObjectIteration](issues/RawObjectIteration.md)
@ -221,6 +228,7 @@
- [TaintedSql](issues/TaintedSql.md)
- [TaintedSSRF](issues/TaintedSSRF.md)
- [TaintedSystemSecret](issues/TaintedSystemSecret.md)
- [TaintedTextWithQuotes](issues/TaintedTextWithQuotes.md)
- [TaintedUnserialize](issues/TaintedUnserialize.md)
- [TaintedUserSecret](issues/TaintedUserSecret.md)
- [TooFewArguments](issues/TooFewArguments.md)
@ -259,14 +267,17 @@
- [UnrecognizedExpression](issues/UnrecognizedExpression.md)
- [UnrecognizedStatement](issues/UnrecognizedStatement.md)
- [UnresolvableInclude](issues/UnresolvableInclude.md)
- [UnsafeGenericInstantiation](issues/UnsafeGenericInstantiation.md)
- [UnsafeInstantiation](issues/UnsafeInstantiation.md)
- [UnusedClass](issues/UnusedClass.md)
- [UnusedClosureParam](issues/UnusedClosureParam.md)
- [UnusedConstructor](issues/UnusedConstructor.md)
- [UnusedForeachValue](issues/UnusedForeachValue.md)
- [UnusedFunctionCall](issues/UnusedFunctionCall.md)
- [UnusedMethod](issues/UnusedMethod.md)
- [UnusedMethodCall](issues/UnusedMethodCall.md)
- [UnusedParam](issues/UnusedParam.md)
- [UnusedProperty](issues/UnusedProperty.md)
- [UnusedPsalmSuppress](issues/UnusedPsalmSuppress.md)
- [UnusedReturnValue](issues/UnusedReturnValue.md)
- [UnusedVariable](issues/UnusedVariable.md)

View File

@ -0,0 +1,3 @@
# ComplexFunction
Emitted when a function is too complicated. Complicated functions should be split up.

View File

@ -0,0 +1,3 @@
# ComplexMethod
Emitted when a method is too complicated. Complicated methods should be split up.

View File

@ -1,7 +1,7 @@
# ForbiddenEcho
Emitted when Psalm encounters an echo statement and the `forbidEcho` flag in your config is set to `true`
This issue is deprecated and will be removed in Psalm 5. Adding echo to forbiddenFunctions in config will result in ForbiddenCode issue instead
```php
<?php

View File

@ -344,6 +344,11 @@ class Config
*/
public $seal_all_methods = false;
/**
* @var bool
*/
public $seal_all_properties = false;
/**
* @var bool
*/
@ -910,6 +915,7 @@ class Config
'reportMixedIssues' => 'show_mixed_issues',
'skipChecksOnUnresolvableIncludes' => 'skip_checks_on_unresolvable_includes',
'sealAllMethods' => 'seal_all_methods',
'sealAllProperties' => 'seal_all_properties',
'runTaintAnalysis' => 'run_taint_analysis',
'usePhpStormMetaPath' => 'use_phpstorm_meta_path',
'allowInternalNamedArgumentsCalls' => 'allow_internal_named_arg_calls',
@ -1180,21 +1186,23 @@ class Config
}
if (isset($config_xml->issueHandlers)) {
/** @var SimpleXMLElement $issue_handler */
foreach ($config_xml->issueHandlers->children() as $key => $issue_handler) {
if ($key === 'PluginIssue') {
$custom_class_name = (string) $issue_handler['name'];
/** @var string $key */
$config->issue_handlers[$custom_class_name] = IssueHandler::loadFromXMLElement(
$issue_handler,
$base_dir
);
} else {
/** @var string $key */
$config->issue_handlers[$key] = IssueHandler::loadFromXMLElement(
$issue_handler,
$base_dir
);
foreach ($config_xml->issueHandlers as $issue_handlers) {
/** @var SimpleXMLElement $issue_handler */
foreach ($issue_handlers->children() as $key => $issue_handler) {
if ($key === 'PluginIssue') {
$custom_class_name = (string) $issue_handler['name'];
/** @var string $key */
$config->issue_handlers[$custom_class_name] = IssueHandler::loadFromXMLElement(
$issue_handler,
$base_dir
);
} else {
/** @var string $key */
$config->issue_handlers[$key] = IssueHandler::loadFromXMLElement(
$issue_handler,
$base_dir
);
}
}
}
}

View File

@ -1938,6 +1938,7 @@ class ClassAnalyzer extends ClassLikeAnalyzer
$method_analyzer,
$interface_return_type,
$interface_class,
$original_fq_classlike_name,
$interface_return_type_location,
[$analyzed_method_id->__toString()],
$did_explicitly_return
@ -1963,6 +1964,7 @@ class ClassAnalyzer extends ClassLikeAnalyzer
$method_analyzer,
$return_type,
$fq_classlike_name,
$original_fq_classlike_name,
$return_type_location,
$overridden_method_ids,
$did_explicitly_return

View File

@ -80,6 +80,7 @@ class ReturnTypeAnalyzer
FunctionLikeAnalyzer $function_like_analyzer,
?Union $return_type = null,
?string $fq_class_name = null,
?string $static_fq_class_name = null,
?CodeLocation $return_type_location = null,
array $compatible_method_ids = [],
bool $did_explicitly_return = false,
@ -421,7 +422,7 @@ class ReturnTypeAnalyzer
$codebase,
$return_type,
$self_fq_class_name,
$self_fq_class_name,
$static_fq_class_name,
$parent_class,
true,
true,

View File

@ -464,6 +464,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer
$this->track_mutations = true;
}
if ($this->function instanceof ArrowFunction && $storage->return_type && $storage->return_type->isNever()) {
// ArrowFunction perform a return implicitly so if the return type is never, we have to suppress the error
// note: the never can only come from phpdoc. PHP will refuse short closures with never in signature
$statements_analyzer->addSuppressedIssues(['NoValue']);
}
$statements_analyzer->analyze($function_stmts, $context, $global_context, true);
if ($codebase->alter_code
@ -1413,6 +1419,7 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer
$this,
$return_type,
$fq_class_name,
$fq_class_name,
$return_type_location,
[],
$did_explicitly_return,

View File

@ -91,6 +91,16 @@ class ScopeAnalyzer
($stmt instanceof PhpParser\Node\Stmt\Expression && $stmt->expr instanceof PhpParser\Node\Expr\Exit_)
) {
if (!$return_is_exit && $stmt instanceof PhpParser\Node\Stmt\Return_) {
$stmt_return_type = null;
if ($nodes && $stmt->expr) {
$stmt_return_type = $nodes->getType($stmt->expr);
}
// don't consider a return if the expression never returns (e.g. a throw inside a short closure)
if ($stmt_return_type && ($stmt_return_type->isNever() || $stmt_return_type->isEmpty())) {
return array_values(array_unique(array_merge($control_actions, [self::ACTION_END])));
}
return array_values(array_unique(array_merge($control_actions, [self::ACTION_RETURN])));
}

View File

@ -38,20 +38,27 @@ use Psalm\Storage\MethodStorage;
use Psalm\Type;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TCallable;
use Psalm\Type\Atomic\TCallableArray;
use Psalm\Type\Atomic\TCallableKeyedArray;
use Psalm\Type\Atomic\TCallableList;
use Psalm\Type\Atomic\TClosure;
use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TList;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TNonEmptyArray;
use Psalm\Type\Atomic\TNonEmptyList;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;
use UnexpectedValueException;
use function array_map;
use function array_reverse;
use function array_slice;
use function count;
use function in_array;
use function is_string;
use function max;
use function min;
use function reset;
use function strpos;
use function strtolower;
@ -557,43 +564,12 @@ class ArgumentsAnalyzer
$has_packed_var = false;
$packed_var_definite_args = 0;
foreach ($args as $arg) {
if ($arg->unpack) {
$arg_value_type = $statements_analyzer->node_data->getType($arg->value);
if (!$arg_value_type
|| !$arg_value_type->isSingle()
|| !$arg_value_type->hasArray()
) {
$has_packed_var = true;
break;
}
foreach ($arg_value_type->getAtomicTypes() as $atomic_arg_type) {
if (!$atomic_arg_type instanceof TKeyedArray) {
$has_packed_var = true;
break 2;
}
$packed_var_definite_args = 0;
foreach ($atomic_arg_type->properties as $property_type) {
if ($property_type->possibly_undefined) {
$has_packed_var = true;
} else {
$packed_var_definite_args++;
}
}
}
$has_packed_var = true;
}
}
if (!$has_packed_var) {
$packed_var_definite_args = max(0, $packed_var_definite_args - 1);
}
$last_param = $function_params
? $function_params[count($function_params) - 1]
: null;
@ -951,9 +927,7 @@ class ArgumentsAnalyzer
$in_call_map,
$method_id,
$cased_method_id,
$code_location,
$has_packed_var,
$packed_var_definite_args
$code_location
);
return null;
@ -1465,9 +1439,7 @@ class ArgumentsAnalyzer
bool $in_call_map,
$method_id,
?string $cased_method_id,
CodeLocation $code_location,
bool $has_packed_var,
int $packed_var_definite_args
CodeLocation $code_location
): void {
if (!$is_variadic
&& count($args) > count($function_params)
@ -1491,48 +1463,113 @@ class ArgumentsAnalyzer
return;
}
if (!$has_packed_var && count($args) < count($function_params)) {
if ($function_storage) {
$expected_param_count = $function_storage->required_param_count;
} else {
for ($i = 0, $j = count($function_params); $i < $j; ++$i) {
$param = $function_params[$i];
if (count($args) < count($function_params)) {
//we're gonna loop over given args and unset them from the function_params.
// If some mandatory params are left at the end, we'll throw an error
foreach ($args as $arg) {
// when the argument is not named, we can remove the params in order
if ($arg->name === null) {
// if we're unpacking, we try to unset the exact number of params, if we can't we give up and return
if ($arg->unpack) {
$arg_value_type = $statements_analyzer->node_data->getType($arg->value);
if ($param->is_optional || $param->is_variadic) {
break;
if (!$arg_value_type || !$arg_value_type->hasArray()) {
return;
}
if ($arg_value_type->isSingle()
&& ($atomic_arg_type = $arg_value_type->getSingleAtomic())
&& $atomic_arg_type instanceof TKeyedArray
&& !$atomic_arg_type->is_list
) {
//if we have a single shape, we'll check param names
foreach ($atomic_arg_type->properties as $property_name => $_property_type) {
foreach ($function_params as $k => $param) {
if ($param->name === $property_name) {
unset($function_params[$k]);
}
}
}
continue;
}
foreach ($arg_value_type->getAtomicTypes() as $atomic_arg_type) {
$packed_var_definite_args_tmp = [];
if ($atomic_arg_type instanceof TCallableArray ||
$atomic_arg_type instanceof TCallableList ||
$atomic_arg_type instanceof TCallableKeyedArray
) {
$packed_var_definite_args_tmp[] = 2;
} elseif ($atomic_arg_type instanceof TKeyedArray) {
if (!$atomic_arg_type->sealed) {
return;
}
foreach ($atomic_arg_type->properties as $property_type) {
if ($property_type->possibly_undefined) {
return;
}
}
//we did not return. The number of packed params is the number of properties
$packed_var_definite_args_tmp[] = count($atomic_arg_type->properties);
} elseif ($atomic_arg_type instanceof TNonEmptyArray ||
$atomic_arg_type instanceof TNonEmptyList
) {
if ($atomic_arg_type->count === null) {
return;
}
$packed_var_definite_args_tmp[] = $atomic_arg_type->count;
} elseif ($atomic_arg_type instanceof TArray
&& $atomic_arg_type->type_params[1]->isEmpty()
) {
$packed_var_definite_args_tmp[] = 0;
} else {
return;
}
if (min($packed_var_definite_args_tmp) === max($packed_var_definite_args_tmp)) {
//we have a stable number of params
$packed_var_definite_args = $packed_var_definite_args_tmp[0];
} else {
return;
}
}
} else {
//if we're not unpacking, we remove the first param
$packed_var_definite_args = 1;
}
$function_params = array_slice($function_params, $packed_var_definite_args);
continue;
}
$expected_param_count = $i;
foreach ($function_params as $k => $param) {
if ($param->name === $arg->name->name) {
unset($function_params[$k]);
continue;
}
}
}
for ($i = count($args) + $packed_var_definite_args, $j = count($function_params); $i < $j; ++$i) {
$param = $function_params[$i];
if (!$param->is_optional
&& !$param->is_variadic
&& ($in_call_map
|| !$function_storage instanceof MethodStorage
|| $function_storage->is_static
|| ($method_id instanceof MethodIdentifier
&& $method_id->method_name === '__construct'))
) {
//we're now left with an array of params that were not passed.
// If they're mandatory, throw an error. Otherwise, we compute the default value
foreach ($function_params as $i => $param) {
if (!$param->is_optional && !$param->is_variadic) {
IssueBuffer::maybeAdd(
new TooFewArguments(
'Too few arguments for ' . $cased_method_id
. ' - expecting ' . $expected_param_count
. ' but saw ' . (count($args) + $packed_var_definite_args),
. ' - expecting ' . $param->name . ' to be passed',
$code_location,
(string)$method_id
),
$statements_analyzer->getSuppressedIssues()
);
break;
continue;
}
if ($param->is_optional
&& $param->type
if ($param->type
&& $param->default_type
&& !$param->is_variadic
&& $template_result

View File

@ -325,12 +325,13 @@ class FunctionCallReturnTypeFetcher
if (!$call_args) {
switch ($call_map_key) {
case 'hrtime':
return new Union([
new TKeyedArray([
Type::getInt(),
Type::getInt()
])
$keyed_array = new TKeyedArray([
Type::getInt(),
Type::getInt()
]);
$keyed_array->sealed = true;
$keyed_array->is_list = true;
return new Union([$keyed_array]);
case 'get_called_class':
return new Union([
@ -438,20 +439,19 @@ class FunctionCallReturnTypeFetcher
return $int;
}
$keyed_array = new TKeyedArray([
Type::getInt(),
Type::getInt()
]);
$keyed_array->sealed = true;
$keyed_array->is_list = true;
if ((string) $first_arg_type === 'false') {
return new Union([
new TKeyedArray([
Type::getInt(),
Type::getInt()
])
]);
return new Union([$keyed_array]);
}
return new Union([
new TKeyedArray([
Type::getInt(),
Type::getInt()
]),
$keyed_array,
new TInt()
]);
}

View File

@ -539,7 +539,7 @@ class ExistingAtomicMethodCallAnalyzer extends CallAnalyzer
case '__set':
// If `@psalm-seal-properties` is set, the property must be defined with
// a `@property` annotation
if ($class_storage->sealed_properties
if (($class_storage->sealed_properties || $codebase->config->seal_all_properties)
&& !isset($class_storage->pseudo_property_set_types['$' . $prop_name])
&& IssueBuffer::accepts(
new UndefinedThisPropertyAssignment(
@ -638,7 +638,7 @@ class ExistingAtomicMethodCallAnalyzer extends CallAnalyzer
case '__get':
// If `@psalm-seal-properties` is set, the property must be defined with
// a `@property` annotation
if ($class_storage->sealed_properties
if (($class_storage->sealed_properties || $codebase->config->seal_all_properties)
&& !isset($class_storage->pseudo_property_get_types['$' . $prop_name])
&& IssueBuffer::accepts(
new UndefinedThisPropertyFetch(

View File

@ -676,7 +676,9 @@ class AtomicPropertyFetchAnalyzer
* If we have an explicit list of all allowed magic properties on the class, and we're
* not in that list, fall through
*/
if (!$class_storage->sealed_properties && !$override_property_visibility) {
if (!($class_storage->sealed_properties || $codebase->config->seal_all_properties)
&& !$override_property_visibility
) {
return false;
}

View File

@ -103,6 +103,11 @@ class LanguageServer extends Dispatcher
*/
protected $onchange_paths_to_analyze = [];
/**
* @var array<string, list<IssueData>>
*/
protected $current_issues = [];
public function __construct(
ProtocolReader $reader,
ProtocolWriter $writer,
@ -365,6 +370,7 @@ class LanguageServer extends Dispatcher
public function emitIssues(array $uris): void
{
$data = IssueBuffer::clear();
$this->current_issues = $data;
foreach ($uris as $file_path => $uri) {
$diagnostics = array_map(
@ -560,4 +566,14 @@ class LanguageServer extends Dispatcher
return $filepath;
}
/**
* Get the value of current_issues
*
* @return array<string, list<IssueData>>
*/
public function getCurrentIssues(): array
{
return $this->current_issues;
}
}

View File

@ -24,7 +24,6 @@ use Psalm\Codebase;
use Psalm\Exception\UnanalyzedFileException;
use Psalm\Internal\Analyzer\ProjectAnalyzer;
use Psalm\Internal\LanguageServer\LanguageServer;
use Psalm\IssueBuffer;
use UnexpectedValueException;
use function array_combine;
@ -367,9 +366,15 @@ class TextDocument
$this->codebase->analyzer->addFilesToAnalyze(
array_combine($all_file_paths_to_analyze, $all_file_paths_to_analyze)
);
$this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false);
$issues = IssueBuffer::clear();
try {
$this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false);
} catch (UnexpectedValueException $e) {
error_log('codeAction errored on file ' . $file_path. ', Reason: '.$e->getMessage());
return new Success(null);
}
$issues = $this->server->getCurrentIssues();
if (empty($issues[$file_path])) {
return new Success(null);

View File

@ -53,6 +53,7 @@ class ArrayMergeReturnTypeProvider implements FunctionReturnTypeProviderInterfac
$codebase = $statements_source->getCodebase();
$generic_properties = [];
$class_strings = [];
$all_keyed_arrays = true;
$all_int_offsets = true;
$all_nonempty_lists = true;
@ -110,6 +111,10 @@ class ArrayMergeReturnTypeProvider implements FunctionReturnTypeProviderInterfac
continue;
}
if (isset($unpacked_type_part->class_strings[$key])) {
$class_strings[$key] = true;
}
if (!isset($generic_properties[$key]) || !$type->possibly_undefined) {
$generic_properties[$key] = $type;
} else {
@ -221,6 +226,10 @@ class ArrayMergeReturnTypeProvider implements FunctionReturnTypeProviderInterfac
) {
$objectlike = new TKeyedArray($generic_properties);
if ($class_strings !== []) {
$objectlike->class_strings = $class_strings;
}
if ($all_nonempty_lists || $all_int_offsets) {
$objectlike->is_list = true;
}

View File

@ -20,6 +20,7 @@ use Psalm\IssueBuffer;
use Psalm\Type;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TArrayKey;
use Psalm\Type\Atomic\TClassConstant;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TEnumCase;
@ -36,7 +37,9 @@ use Psalm\Type\Atomic\TLiteralInt;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TNumeric;
use Psalm\Type\Atomic\TPositiveInt;
use Psalm\Type\Atomic\TScalar;
use Psalm\Type\Atomic\TString;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Reconciler;
@ -929,7 +932,27 @@ class AssertionReconciler extends Reconciler
$scalar_type = substr($assertion, 0, $bracket_pos);
$existing_var_atomic_types = $existing_var_type->getAtomicTypes();
$existing_var_atomic_types = [];
foreach ($existing_var_type->getAtomicTypes() as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TClassConstant) {
$expanded = TypeExpander::expandAtomic(
$statements_analyzer->getCodebase(),
$existing_var_atomic_type,
$existing_var_atomic_type->fq_classlike_name,
$existing_var_atomic_type->fq_classlike_name,
null,
true,
true
);
foreach ($expanded as $atomic_type) {
$existing_var_atomic_types[$atomic_type->getKey()] = $atomic_type;
}
} else {
$existing_var_atomic_types[$existing_var_atomic_type->getKey()] = $existing_var_atomic_type;
}
}
if ($scalar_type === 'int') {
return self::handleLiteralEqualityWithInt(
@ -938,6 +961,7 @@ class AssertionReconciler extends Reconciler
$bracket_pos,
$is_loose_equality,
$existing_var_type,
$existing_var_atomic_types,
$old_var_type_string,
$var_id,
$negated,
@ -950,209 +974,34 @@ class AssertionReconciler extends Reconciler
|| $scalar_type === 'callable-string'
|| $scalar_type === 'trait-string'
) {
if ($existing_var_type->hasMixed()
|| $existing_var_type->hasScalar()
|| $existing_var_type->hasArrayKey()
) {
if ($is_loose_equality) {
return $existing_var_type;
}
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
return new Union([new TLiteralClassString($value)]);
}
return new Union([new TLiteralString($value)]);
}
$has_string = false;
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TString) {
$has_string = true;
} elseif ($existing_var_atomic_type instanceof TTemplateParam) {
if ($existing_var_atomic_type->as->hasMixed()
|| $existing_var_atomic_type->as->hasString()
|| $existing_var_atomic_type->as->hasScalar()
|| $existing_var_atomic_type->as->hasArrayKey()
) {
if ($is_loose_equality) {
return $existing_var_type;
}
$existing_var_atomic_type = clone $existing_var_atomic_type;
$existing_var_atomic_type->as = self::handleLiteralEquality(
$statements_analyzer,
$assertion,
$bracket_pos,
false,
$existing_var_atomic_type->as,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
return new Union([$existing_var_atomic_type]);
}
if ($existing_var_atomic_type->as->hasString()) {
$has_string = true;
}
}
}
if ($has_string) {
$existing_string_types = $existing_var_type->getLiteralStrings();
if ($existing_string_types) {
$can_be_equal = false;
$did_remove_type = false;
foreach ($existing_var_atomic_types as $atomic_key => $_) {
if ($atomic_key !== $assertion) {
$existing_var_type->removeType($atomic_key);
$did_remove_type = true;
} else {
$can_be_equal = true;
}
}
if ($var_id
&& $code_location
&& (!$can_be_equal || (!$did_remove_type && count($existing_var_atomic_types) === 1))
) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
$can_be_equal,
$negated,
$code_location,
$suppressed_issues
);
}
} else {
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
$existing_var_type = new Union([new TLiteralClassString($value)]);
} else {
$existing_var_type = new Union([new TLiteralString($value)]);
}
}
} elseif ($var_id && $code_location && !$is_loose_equality) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
false,
$negated,
$code_location,
$suppressed_issues
);
}
return self::handleLiteralEqualityWithString(
$statements_analyzer,
$assertion,
$scalar_type,
$bracket_pos,
$is_loose_equality,
$existing_var_type,
$existing_var_atomic_types,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
} elseif ($scalar_type === 'float') {
$value = (float) $value;
if ($existing_var_type->hasMixed() || $existing_var_type->hasScalar() || $existing_var_type->hasNumeric()) {
if ($is_loose_equality) {
return $existing_var_type;
}
return new Union([new TLiteralFloat($value)]);
}
if ($existing_var_type->hasFloat()) {
$existing_float_types = $existing_var_type->getLiteralFloats();
if ($existing_float_types) {
$can_be_equal = false;
$did_remove_type = false;
foreach ($existing_var_atomic_types as $atomic_key => $_) {
if ($atomic_key !== $assertion) {
$existing_var_type->removeType($atomic_key);
$did_remove_type = true;
} else {
$can_be_equal = true;
}
}
if ($var_id
&& $code_location
&& (!$can_be_equal || (!$did_remove_type && count($existing_var_atomic_types) === 1))
) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
$can_be_equal,
$negated,
$code_location,
$suppressed_issues
);
}
} else {
$existing_var_type = new Union([new TLiteralFloat($value)]);
}
} elseif ($var_id && $code_location && !$is_loose_equality) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
false,
$negated,
$code_location,
$suppressed_issues
);
} elseif ($is_loose_equality && $existing_var_type->hasInt()) {
// convert ints to floats
$existing_float_types = $existing_var_type->getLiteralInts();
if ($existing_float_types) {
$can_be_equal = false;
$did_remove_type = false;
foreach ($existing_var_atomic_types as $atomic_key => $_) {
if (strpos($atomic_key, 'int(') === 0) {
$atomic_key = 'float(' . substr($atomic_key, 4);
}
if ($atomic_key !== $assertion) {
$existing_var_type->removeType($atomic_key);
$did_remove_type = true;
} else {
$can_be_equal = true;
}
}
if ($var_id
&& $code_location
&& (!$can_be_equal || (!$did_remove_type && count($existing_var_atomic_types) === 1))
) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
$can_be_equal,
$negated,
$code_location,
$suppressed_issues
);
}
}
}
return self::handleLiteralEqualityWithFloat(
$statements_analyzer,
$assertion,
$bracket_pos,
$is_loose_equality,
$existing_var_type,
$existing_var_atomic_types,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
} elseif ($scalar_type === 'enum') {
[$fq_enum_name, $case_name] = explode('::', $value);
@ -1204,7 +1053,8 @@ class AssertionReconciler extends Reconciler
}
/**
* @param string[] $suppressed_issues
* @param array<string, Atomic> $existing_var_atomic_types
* @param string[] $suppressed_issues
*/
private static function handleLiteralEqualityWithInt(
StatementsAnalyzer $statements_analyzer,
@ -1212,6 +1062,7 @@ class AssertionReconciler extends Reconciler
int $bracket_pos,
bool $is_loose_equality,
Union $existing_var_type,
array $existing_var_atomic_types,
string $old_var_type_string,
?string $var_id,
bool $negated,
@ -1220,92 +1071,119 @@ class AssertionReconciler extends Reconciler
): Union {
$value = (int) substr($assertion, $bracket_pos + 1, -1);
$compatible_int_type = self::getCompatibleIntType($existing_var_type, $value, $is_loose_equality);
// we create the literal that is being asserted. We'll return this when we're sure this is the resulting type
$literal_asserted_type = new Union([new TLiteralInt($value)]);
$literal_asserted_type->from_docblock = $existing_var_type->from_docblock;
$compatible_int_type = self::getCompatibleIntType(
$existing_var_type,
$existing_var_atomic_types,
$value,
$is_loose_equality
);
if ($compatible_int_type !== null) {
return $compatible_int_type;
}
$has_int = false;
$existing_var_atomic_types = $existing_var_type->getAtomicTypes();
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TInt) {
$has_int = true;
} elseif ($existing_var_atomic_type instanceof TTemplateParam) {
$compatible_int_type = self::getCompatibleIntType($existing_var_type, $value, $is_loose_equality);
if ($existing_var_atomic_type instanceof TPositiveInt && $value > 0) {
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TIntRange && $existing_var_atomic_type->contains($value)) {
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TLiteralInt && $existing_var_atomic_type->value === $value) {
//if we're here, we check that we had at least another type in the union, otherwise it's redundant
if ($existing_var_type->isSingleIntLiteral()) {
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
true,
$negated,
$code_location,
$suppressed_issues
);
}
return $existing_var_type;
}
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TInt && !$existing_var_atomic_type instanceof TLiteralInt) {
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TTemplateParam) {
$compatible_int_type = self::getCompatibleIntType(
$existing_var_type,
$existing_var_atomic_type->as->getAtomicTypes(),
$value,
$is_loose_equality
);
if ($compatible_int_type !== null) {
return $compatible_int_type;
}
if ($existing_var_atomic_type->as->hasInt()) {
$has_int = true;
}
} elseif ($existing_var_atomic_type instanceof TClassConstant) {
$expanded = TypeExpander::expandAtomic(
$statements_analyzer->getCodebase(),
$existing_var_atomic_type,
$existing_var_atomic_type->fq_classlike_name,
$existing_var_atomic_type->fq_classlike_name,
null,
true,
true
$existing_var_atomic_type = clone $existing_var_atomic_type;
$existing_var_atomic_type->as = self::handleLiteralEquality(
$statements_analyzer,
$assertion,
$bracket_pos,
false,
$existing_var_atomic_type->as,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
foreach ($expanded as $expanded_type) {
$compatible_int_type = self::getCompatibleIntType(
$existing_var_type,
$value,
$is_loose_equality
);
return new Union([$existing_var_atomic_type]);
}
if ($compatible_int_type !== null) {
return $compatible_int_type;
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralFloat
&& (int)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
if ($expanded_type instanceof TInt) {
$has_int = true;
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralString
&& (int)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
}
//here we'll accept non-literal type that *could* match on loose equality and return the original type
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
//here we'll accept non-literal type that *could* match on loose equality and return the original type
if ($is_loose_equality) {
if ($existing_var_atomic_type instanceof TString
&& !$existing_var_atomic_type instanceof TLiteralString
) {
return $existing_var_type;
}
if ($existing_var_atomic_type instanceof TFloat
&& !$existing_var_atomic_type instanceof TLiteralFloat
) {
return $existing_var_type;
}
}
}
if ($has_int) {
$existing_int_types = $existing_var_type->getLiteralInts();
if ($existing_int_types) {
$can_be_equal = false;
$did_remove_type = false;
foreach ($existing_var_atomic_types as $atomic_key => $atomic_type) {
if ($atomic_key !== $assertion
&& !($atomic_type instanceof TPositiveInt && $value > 0)
&& !($atomic_type instanceof TIntRange && $atomic_type->contains($value))
) {
$existing_var_type->removeType($atomic_key);
$did_remove_type = true;
} else {
$can_be_equal = true;
}
}
if ($var_id
&& $code_location
&& (!$can_be_equal || (!$did_remove_type && count($existing_var_atomic_types) === 1))
) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
$can_be_equal,
$negated,
$code_location,
$suppressed_issues
);
}
} else {
$existing_var_type = new Union([new TLiteralInt($value)]);
}
} elseif ($var_id && $code_location && !$is_loose_equality) {
//if we're here, no type was eligible for the given literal. We'll emit an impossible error for this assertion
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
@ -1316,62 +1194,411 @@ class AssertionReconciler extends Reconciler
$code_location,
$suppressed_issues
);
} elseif ($is_loose_equality && $existing_var_type->hasFloat()) {
// convert floats to ints
$existing_float_types = $existing_var_type->getLiteralFloats();
}
if ($existing_float_types) {
$can_be_equal = false;
$did_remove_type = false;
return Type::getNever();
}
foreach ($existing_var_atomic_types as $atomic_key => $_) {
if (strpos($atomic_key, 'float(') === 0) {
$atomic_key = 'int(' . substr($atomic_key, 6);
}
if ($atomic_key !== $assertion) {
$existing_var_type->removeType($atomic_key);
$did_remove_type = true;
} else {
$can_be_equal = true;
/**
* @param array<string, Atomic> $existing_var_atomic_types
* @param string[] $suppressed_issues
*/
private static function handleLiteralEqualityWithString(
StatementsAnalyzer $statements_analyzer,
string $assertion,
string $scalar_type,
int $bracket_pos,
bool $is_loose_equality,
Union $existing_var_type,
array $existing_var_atomic_types,
string $old_var_type_string,
?string $var_id,
bool $negated,
?CodeLocation $code_location,
array $suppressed_issues
): Union {
$value = substr($assertion, $bracket_pos + 1, -1);
// we create the literal that is being asserted. We'll return this when we're sure this is the resulting type
$literal_asserted_type_string = new Union([new TLiteralString($value)]);
$literal_asserted_type_string->from_docblock = $existing_var_type->from_docblock;
$literal_asserted_type_classstring = new Union([new TLiteralClassString($value)]);
$literal_asserted_type_classstring->from_docblock = $existing_var_type->from_docblock;
$compatible_string_type = self::getCompatibleStringType(
$existing_var_type,
$existing_var_atomic_types,
$value,
$scalar_type,
$is_loose_equality
);
if ($compatible_string_type !== null) {
return $compatible_string_type;
}
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TLiteralString && $existing_var_atomic_type->value === $value) {
//if we're here, we check that we had at least another type in the union, otherwise it's redundant
if ($existing_var_type->isSingleStringLiteral()) {
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
true,
$negated,
$code_location,
$suppressed_issues
);
}
return $existing_var_type;
}
if ($var_id
&& $code_location
&& (!$can_be_equal || (!$did_remove_type && count($existing_var_atomic_types) === 1))
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
$can_be_equal,
$negated,
$code_location,
$suppressed_issues
);
return $literal_asserted_type_classstring;
}
return $literal_asserted_type_string;
}
if ($existing_var_atomic_type instanceof TString && !$existing_var_atomic_type instanceof TLiteralString) {
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
return $literal_asserted_type_classstring;
}
return $literal_asserted_type_string;
}
if ($existing_var_atomic_type instanceof TTemplateParam) {
$compatible_string_type = self::getCompatibleStringType(
$existing_var_type,
$existing_var_atomic_type->as->getAtomicTypes(),
$value,
$scalar_type,
$is_loose_equality
);
if ($compatible_string_type !== null) {
return $compatible_string_type;
}
if ($existing_var_atomic_type->as->hasString()) {
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
return $literal_asserted_type_classstring;
}
return $literal_asserted_type_string;
}
$existing_var_atomic_type = clone $existing_var_atomic_type;
$existing_var_atomic_type->as = self::handleLiteralEquality(
$statements_analyzer,
$assertion,
$bracket_pos,
false,
$existing_var_atomic_type->as,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
return new Union([$existing_var_atomic_type]);
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralInt
&& (string)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralFloat
&& (string)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
}
//here we'll accept non-literal type that *could* match on loose equality and return the original type
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
//here we'll accept non-literal type that *could* match on loose equality and return the original type
if ($is_loose_equality) {
if ($existing_var_atomic_type instanceof TInt
&& !$existing_var_atomic_type instanceof TLiteralInt
) {
return $existing_var_type;
}
if ($existing_var_atomic_type instanceof TFloat
&& !$existing_var_atomic_type instanceof TLiteralFloat
) {
return $existing_var_type;
}
}
}
return $existing_var_type;
//if we're here, no type was eligible for the given literal. We'll emit an impossible error for this assertion
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
false,
$negated,
$code_location,
$suppressed_issues
);
}
return Type::getNever();
}
/**
* @param array<string, Atomic> $existing_var_atomic_types
* @param string[] $suppressed_issues
*/
private static function handleLiteralEqualityWithFloat(
StatementsAnalyzer $statements_analyzer,
string $assertion,
int $bracket_pos,
bool $is_loose_equality,
Union $existing_var_type,
array $existing_var_atomic_types,
string $old_var_type_string,
?string $var_id,
bool $negated,
?CodeLocation $code_location,
array $suppressed_issues
): Union {
$value = (float)substr($assertion, $bracket_pos + 1, -1);
// we create the literal that is being asserted. We'll return this when we're sure this is the resulting type
$literal_asserted_type = new Union([new TLiteralFloat($value)]);
$literal_asserted_type->from_docblock = $existing_var_type->from_docblock;
$compatible_float_type = self::getCompatibleFloatType(
$existing_var_type,
$existing_var_atomic_types,
$value,
$is_loose_equality
);
if ($compatible_float_type !== null) {
return $compatible_float_type;
}
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TLiteralFloat && $existing_var_atomic_type->value === $value) {
//if we're here, we check that we had at least another type in the union, otherwise it's redundant
if ($existing_var_type->isSingleFloatLiteral()) {
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
true,
$negated,
$code_location,
$suppressed_issues
);
}
return $existing_var_type;
}
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TFloat && !$existing_var_atomic_type instanceof TLiteralFloat) {
return $literal_asserted_type;
}
if ($existing_var_atomic_type instanceof TTemplateParam) {
$compatible_float_type = self::getCompatibleFloatType(
$existing_var_type,
$existing_var_atomic_type->as->getAtomicTypes(),
$value,
$is_loose_equality
);
if ($compatible_float_type !== null) {
return $compatible_float_type;
}
if ($existing_var_atomic_type->as->hasFloat()) {
return $literal_asserted_type;
}
$existing_var_atomic_type = clone $existing_var_atomic_type;
$existing_var_atomic_type->as = self::handleLiteralEquality(
$statements_analyzer,
$assertion,
$bracket_pos,
false,
$existing_var_atomic_type->as,
$old_var_type_string,
$var_id,
$negated,
$code_location,
$suppressed_issues
);
return new Union([$existing_var_atomic_type]);
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralInt
&& (float)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
if ($is_loose_equality
&& $existing_var_atomic_type instanceof TLiteralString
&& (float)$existing_var_atomic_type->value === $value
) {
return new Union([$existing_var_atomic_type]);
}
}
//here we'll accept non-literal type that *could* match on loose equality and return the original type
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($is_loose_equality) {
if ($existing_var_atomic_type instanceof TInt
&& !$existing_var_atomic_type instanceof TLiteralInt
) {
return $existing_var_type;
}
if ($existing_var_atomic_type instanceof TString
&& !$existing_var_atomic_type instanceof TLiteralString
) {
return $existing_var_type;
}
}
}
//if we're here, no type was eligible for the given literal. We'll emit an impossible error for this assertion
if ($var_id && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$var_id,
$assertion,
false,
$negated,
$code_location,
$suppressed_issues
);
}
return Type::getNever();
}
/**
* @param array<string, Atomic> $existing_var_atomic_types
*/
private static function getCompatibleIntType(
Union $existing_var_type,
array $existing_var_atomic_types,
int $value,
bool $is_loose_equality
): ?Union {
if ($existing_var_type->hasMixed()
|| $existing_var_type->hasScalar()
|| $existing_var_type->hasNumeric()
|| $existing_var_type->hasArrayKey()
) {
if ($is_loose_equality) {
return $existing_var_type;
}
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TMixed
|| $existing_var_atomic_type instanceof TScalar
|| $existing_var_atomic_type instanceof TNumeric
|| $existing_var_atomic_type instanceof TArrayKey
) {
if ($is_loose_equality) {
return $existing_var_type;
}
return new Union([new TLiteralInt($value)]);
$asserted_type = new Union([new TLiteralInt($value)]);
$asserted_type->from_docblock = $existing_var_type->from_docblock;
return $asserted_type;
}
}
return null;
}
/**
* @param array<string, Atomic> $existing_var_atomic_types
*/
private static function getCompatibleStringType(
Union $existing_var_type,
array $existing_var_atomic_types,
string $value,
string $scalar_type,
bool $is_loose_equality
): ?Union {
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TMixed
|| $existing_var_atomic_type instanceof TScalar
|| $existing_var_atomic_type instanceof TArrayKey
) {
if ($is_loose_equality) {
return $existing_var_type;
}
if ($scalar_type === 'class-string'
|| $scalar_type === 'interface-string'
|| $scalar_type === 'trait-string'
) {
$asserted_type = new Union([new TLiteralClassString($value)]);
$asserted_type->from_docblock = $existing_var_type->from_docblock;
return $asserted_type;
}
$asserted_type = new Union([new TLiteralString($value)]);
$asserted_type->from_docblock = $existing_var_type->from_docblock;
return $asserted_type;
}
}
return null;
}
/**
* @param array<string, Atomic> $existing_var_atomic_types
*/
private static function getCompatibleFloatType(
Union $existing_var_type,
array $existing_var_atomic_types,
float $value,
bool $is_loose_equality
): ?Union {
foreach ($existing_var_atomic_types as $existing_var_atomic_type) {
if ($existing_var_atomic_type instanceof TMixed
|| $existing_var_atomic_type instanceof TScalar
|| $existing_var_atomic_type instanceof TNumeric
) {
if ($is_loose_equality) {
return $existing_var_type;
}
$asserted_type = new Union([new TLiteralFloat($value)]);
$asserted_type->from_docblock = $existing_var_type->from_docblock;
return $asserted_type;
}
}
return null;

View File

@ -690,6 +690,24 @@ class ArgTest extends TestCase
',
'error_message' => 'ArgumentTypeCoercion',
],
'MissingMandatoryParamWithNamedParams' => [
'<?php
class User
{
public function __construct(
protected string $name,
protected string $problematicOne,
protected string $id = "",
){}
}
new User(
name: "John",
id: "asd",
);
',
'error_message' => 'TooFewArguments',
],
];
}
}

View File

@ -2121,6 +2121,24 @@ class ArrayFunctionCallTest extends TestCase
return $a;
}',
],
'keepClassStringInOffsetThroughArrayMerge' => [
'<?php
class A {
/** @var array<class-string, string> */
private array $a;
public function __construct() {
$this->a = [];
}
public function handle(): void {
$b = [A::class => "d"];
$this->a = array_merge($this->a, $b);
}
}
',
],
];
}

View File

@ -735,6 +735,31 @@ class ClosureTest extends TestCase
'ignored_issues' => [],
'php_version' => '8.1',
],
'arrowFunctionReturnsNeverImplictly' => [
'<?php
$bar = ["foo", "bar"];
$bam = array_map(
fn(string $a) => throw new Exception($a),
$bar
);',
'assertions' => [],
'error_levels' => [],
'8.1'
],
'arrowFunctionReturnsNeverExplictly' => [
'<?php
$bar = ["foo", "bar"];
$bam = array_map(
/** @return never */
fn(string $a) => die(),
$bar
);',
'assertions' => [],
'error_levels' => [],
'8.1'
],
];
}

View File

@ -105,7 +105,7 @@ class ConfigTest extends TestCase
$config = $this->project_analyzer->getConfig();
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Type.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
}
public function testIgnoreProjectDirectory(): void
@ -129,7 +129,7 @@ class ConfigTest extends TestCase
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Type.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/FileAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
}
public function testIgnoreMissingProjectDirectory(): void
@ -152,8 +152,8 @@ class ConfigTest extends TestCase
$config = $this->project_analyzer->getConfig();
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Type.php')));
$this->assertFalse($config->isInProjectDirs(realpath('does/not/exist/FileAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath(__DIR__.'/../../').'/does/not/exist/FileAnalyzer.php'));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
}
public function testIgnoreSymlinkedProjectDirectory(): void
@ -199,7 +199,7 @@ class ConfigTest extends TestCase
$this->assertTrue($config->isInProjectDirs(realpath('tests/AnnotationTest.php')));
$this->assertFalse($config->isInProjectDirs(realpath('tests/fixtures/symlinktest/a/ignoreme.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
$regex = '/^unlink\([^\)]+\): (?:Permission denied|No such file or directory)$/';
$last_error = error_get_last();
@ -247,7 +247,7 @@ class ConfigTest extends TestCase
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Type.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/FileAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
}
public function testIgnoreWildcardFiles(): void
@ -272,7 +272,7 @@ class ConfigTest extends TestCase
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Type.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/FileAnalyzer.php')));
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/TemplateScanner.php')));
}
public function testIgnoreWildcardFilesInWildcardFolder(): void
@ -328,7 +328,6 @@ class ConfigTest extends TestCase
$this->assertTrue($config->isInProjectDirs(realpath('src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/FileAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php')));
$this->assertFalse($config->isInProjectDirs(realpath('examples/StringAnalyzer.php')));
}
public function testIssueHandler(): void
@ -352,10 +351,38 @@ class ConfigTest extends TestCase
$config = $this->project_analyzer->getConfig();
$this->assertFalse($config->reportIssueInFile('MissingReturnType', realpath('tests/ConfigTest.php')));
$this->assertFalse($config->reportIssueInFile('MissingReturnType', realpath(__FILE__)));
$this->assertFalse($config->reportIssueInFile('MissingReturnType', realpath('src/Psalm/Type.php')));
}
public function testMultipleIssueHandlers(): void
{
$this->project_analyzer = $this->getProjectAnalyzerWithConfig(
Config::loadFromXML(
dirname(__DIR__, 2),
'<?xml version="1.0"?>
<psalm>
<projectFiles>
<directory name="src" />
<directory name="tests" />
</projectFiles>
<issueHandlers>
<MissingReturnType errorLevel="suppress" />
</issueHandlers>
<issueHandlers>
<UndefinedClass errorLevel="suppress" />
</issueHandlers>
</psalm>'
)
);
$config = $this->project_analyzer->getConfig();
$this->assertFalse($config->reportIssueInFile('MissingReturnType', realpath(__FILE__)));
$this->assertFalse($config->reportIssueInFile('UndefinedClass', realpath(__FILE__)));
}
public function testIssueHandlerWithCustomErrorLevels(): void
{
$this->project_analyzer = $this->getProjectAnalyzerWithConfig(

View File

@ -18,26 +18,35 @@ use Psalm\Internal\RuntimeCaches;
use Psalm\Tests\Internal\Provider\FakeParserCacheProvider;
use UnexpectedValueException;
use function array_diff;
use function array_filter;
use function array_keys;
use function array_map;
use function array_shift;
use function count;
use function dirname;
use function explode;
use function file;
use function file_exists;
use function file_get_contents;
use function glob;
use function implode;
use function in_array;
use function preg_match;
use function preg_quote;
use function scandir;
use function sort;
use function str_replace;
use function strlen;
use function strpos;
use function substr;
use function trim;
use function usort;
use function var_export;
use const DIRECTORY_SEPARATOR;
use const FILE_IGNORE_NEW_LINES;
use const FILE_SKIP_EMPTY_LINES;
use const LIBXML_NONET;
class DocumentationTest extends TestCase
@ -90,7 +99,7 @@ class DocumentationTest extends TestCase
*/
private static function getCodeBlocksFromDocs(): array
{
$issues_dir = dirname(dirname(__FILE__)) . DIRECTORY_SEPARATOR . 'docs' . DIRECTORY_SEPARATOR . 'running_psalm' . DIRECTORY_SEPARATOR . 'issues';
$issues_dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'docs' . DIRECTORY_SEPARATOR . 'running_psalm' . DIRECTORY_SEPARATOR . 'issues';
if (!file_exists($issues_dir)) {
throw new UnexpectedValueException('docs not found');
@ -420,4 +429,52 @@ class DocumentationTest extends TestCase
}
};
}
/**
* Tests that issues.md contains the expected links to issue documentation.
* issues.md can be generated automatically with bin/generate_documentation_issues_list.php.
*/
public function testIssuesIndex(): void
{
$docs_dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . "docs" . DIRECTORY_SEPARATOR . "running_psalm" . DIRECTORY_SEPARATOR;
$issues_index = "{$docs_dir}issues.md";
$issues_dir = "{$docs_dir}issues";
if (!file_exists($issues_dir)) {
throw new UnexpectedValueException("Issues documentation not found");
}
if (!file_exists($issues_index)) {
throw new UnexpectedValueException("Issues index not found");
}
$issues_index_contents = file($issues_index, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
array_shift($issues_index_contents); // Remove title
$issues_index_list = array_map(function (string $issues_line) {
preg_match('/^ - \[([^\]]*)\]\(issues\/\1\.md\)$/', $issues_line, $matches);
$this->assertCount(2, $matches, "Invalid format in issues index: $issues_line");
return $matches[1];
}, $issues_index_contents);
$issue_files = array_filter(array_map(function (string $issue_file) {
if ($issue_file === "." || $issue_file === "..") {
return false;
}
$this->assertStringEndsWith(".md", $issue_file, "Invalid file in issues documentation: $issue_file");
return substr($issue_file, 0, strlen($issue_file) - 3);
}, scandir($issues_dir)));
$unlisted_issues = array_diff($issue_files, $issues_index_list);
$this->assertEmpty($unlisted_issues, "Issue documentation missing from issues.md: " . implode(", ", $unlisted_issues));
$missing_documentation = array_diff($issues_index_list, $issue_files);
$this->assertEmpty($missing_documentation, "issues.md has link to non-existent documentation for: " . implode(", ", $missing_documentation));
$sorted = $issues_index_list;
usort($sorted, "strcasecmp");
for ($i = 0; $i < count($sorted); ++$i) {
$this->assertEquals($sorted[$i], $issues_index_list[$i], "issues.md out of order, expected {$sorted[$i]} before {$issues_index_list[$i]}");
}
}
}

View File

@ -1054,9 +1054,9 @@ class FunctionCallTest extends TestCase
$d = hrtime(false);',
'assertions' => [
'$a' => 'int',
'$b' => 'array{0: int, 1: int}',
'$c' => 'array{0: int, 1: int}|int',
'$d' => 'array{0: int, 1: int}',
'$b' => 'array{int, int}',
'$c' => 'array{int, int}|int',
'$d' => 'array{int, int}',
],
],
'hrtimeCanBeFloat' => [
@ -2160,7 +2160,7 @@ class FunctionCallTest extends TestCase
'tooFewArgsAccurateCount' => [
'code' => '<?php
preg_match(\'/adsf/\');',
'error_message' => 'TooFewArguments - src' . DIRECTORY_SEPARATOR . 'somefile.php:2:21 - Too few arguments for preg_match - expecting 2 but saw 1',
'error_message' => 'TooFewArguments - src' . DIRECTORY_SEPARATOR . 'somefile.php:2:21 - Too few arguments for preg_match - expecting subject to be passed',
],
'compactUndefinedVariable' => [
'code' => '<?php

View File

@ -686,6 +686,9 @@ class IntRangeTest extends TestCase
assert($length === 1);
',
'assertions' => [
'$length===' => '1',
],
],
];
}

View File

@ -8,7 +8,7 @@ use Psalm\Tests\TestCase;
class InternalCallMapHandlerTest extends TestCase
{
/**
* @covers InternalCallMapHandler::getCallMap
* @covers \Psalm\Internal\Codebase\InternalCallMapHandler::getCallMap
*/
public function testGetcallmapReturnsAValidCallmap(): void
{

View File

@ -4,6 +4,7 @@ namespace Psalm\Tests;
use Psalm\Config;
use Psalm\Context;
use Psalm\Exception\CodeException;
use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait;
use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait;
@ -1159,4 +1160,28 @@ class MagicPropertyTest extends TestCase
],
];
}
public function testSealAllMethodsWithoutFoo(): void
{
Config::getInstance()->seal_all_properties = true;
$this->addFile(
'somefile.php',
'<?php
class A {
public function __get(string $name) {}
}
class B extends A {}
$b = new B();
$result = $b->foo;
'
);
$error_message = 'UndefinedMagicPropertyFetch';
$this->expectException(CodeException::class);
$this->expectExceptionMessage($error_message);
$this->analyzeFile('somefile.php', new Context());
}
}

View File

@ -247,6 +247,27 @@ class ReturnTypeTest extends TestCase
'$bees' => 'array<int, B>',
],
],
'extendsStaticConstReturnType' => [
'<?php
class A {
/** @var int */
private const FOO = 1;
/** @return static::FOO */
public function getFoo() {
return self::FOO;
}
}
class B extends A {
/** @var int */
private const FOO = 2;
public function getFoo() {
return self::FOO;
}
}',
],
'issetReturnType' => [
'code' => '<?php
/**