diff --git a/conf/config.level7.neon b/conf/config.level7.neon index df33adf94d..d9383647e4 100644 --- a/conf/config.level7.neon +++ b/conf/config.level7.neon @@ -5,3 +5,6 @@ parameters: checkUnionTypes: true reportMaybes: true checkListType: true + +rules: + - PHPStan\Rules\Cast\CastObjectToStringRule diff --git a/resources/functionMap.php b/resources/functionMap.php index 50731270cc..7d76f2bf56 100644 --- a/resources/functionMap.php +++ b/resources/functionMap.php @@ -3000,7 +3000,7 @@ 'fpassthru' => ['0|positive-int|false', 'fp'=>'resource'], 'fpm_get_status' => ['array{pool: string, process-manager: \'dynamic\'|\'ondemand\'|\'static\', start-time: int<0, max>, start-since: int<0, max>, accepted-conn: int<0, max>, listen-queue: int<0, max>, max-listen-queue: int<0, max>, listen-queue-len: int<0, max>, idle-processes: int<0, max>, active-processes: int<1, max>, total-processes: int<1, max>, max-active-processes: int<1, max>, max-children-reached: 0|1, slow-requests: int<0, max>, procs: array, state: \'Idle\'|\'Running\', start-time: int<0, max>, start-since: int<0, max>, requests: int<0, max>, request-duration: int<0, max>, request-method: string, request-uri: string, query-string: string, request-length: int<0, max>, user: string, script: string, last-request-cpu: float, last-request-memory: int<0, max>}>}|false'], 'fprintf' => ['int', 'stream'=>'resource', 'format'=>'string', '...values='=>'string|int|float'], -'fputcsv' => ['0|positive-int|false', 'fp'=>'resource', 'fields'=>'array', 'delimiter='=>'string', 'enclosure='=>'string', 'escape_char='=>'string'], +'fputcsv' => ['0|positive-int|false', 'fp'=>'resource', 'fields'=>'array', 'delimiter='=>'string', 'enclosure='=>'string', 'escape_char='=>'string'], 'fputs' => ['0|positive-int|false', 'fp'=>'resource', 'str'=>'string', 'length='=>'0|positive-int'], 'fread' => ['string|false', 'fp'=>'resource', 'length'=>'0|positive-int'], 'frenchtojd' => ['int', 'month'=>'int', 'day'=>'int', 'year'=>'int'], @@ -8846,7 +8846,7 @@ 'preg_split' => ['list|false', 'pattern'=>'string', 'subject'=>'string', 'limit='=>'?int', 'flags='=>'int'], 'prev' => ['mixed', '&rw_array_arg'=>'array|object'], 'print_r' => ['string|true', 'var'=>'mixed', 'return='=>'bool'], -'printf' => ['int', 'format'=>'string', '...values='=>'string|int|float'], +'printf' => ['int', 'format'=>'string', '...values='=>'string|stringable-object|int|float|bool|null'], 'proc_close' => ['int', 'process'=>'resource'], 'proc_get_status' => ['array{command: string, pid: int, running: bool, signaled: bool, stopped: bool, exitcode: int, termsig: int, stopsig: int}|false', 'process'=>'resource'], 'proc_nice' => ['bool', 'priority'=>'int'], @@ -11404,7 +11404,7 @@ 'Spoofchecker::setAllowedLocales' => ['void', 'locale_list'=>'string'], 'Spoofchecker::setChecks' => ['void', 'checks'=>'long'], 'Spoofchecker::setRestrictionLevel' => ['void', 'restriction_level'=>'int'], -'sprintf' => ['string', 'format'=>'string', '...values='=>'string|int|float|bool'], +'sprintf' => ['string', 'format'=>'string', '...values='=>'string|stringable-object|int|float|bool|null'], 'sql_regcase' => ['string', 'string'=>'string'], 'SQLite3::__construct' => ['void', 'filename'=>'string', 'flags='=>'int', 'encryption_key='=>'string|null'], 'SQLite3::busyTimeout' => ['bool', 'msecs'=>'int'], diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index d4f9fff964..607bde344d 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -44,6 +44,7 @@ use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\HasMethodType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; @@ -325,6 +326,16 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco case 'object': return new ObjectWithoutClassType(); + case 'stringable-object': + if ($this->getReflectionProvider()->hasClass('Stringable')) { + $classReflection = $this->getReflectionProvider()->getClass('Stringable'); + if ($classReflection->isBuiltin() && $classReflection->hasNativeMethod('__toString')) { + return new ObjectType('Stringable', null, $classReflection); + } + } + + return new IntersectionType([new ObjectWithoutClassType(), new HasMethodType('__toString')]); + case 'never': $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 12ae34d53e..442ee2cb87 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -13,19 +13,11 @@ use PHPStan\Reflection\Native\NativeFunctionReflection; use PHPStan\Reflection\Native\NativeParameterWithPhpDocsReflection; use PHPStan\TrinaryLogic; -use PHPStan\Type\ArrayType; -use PHPStan\Type\BooleanType; use PHPStan\Type\FileTypeMapper; -use PHPStan\Type\FloatType; use PHPStan\Type\Generic\TemplateTypeMap; -use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; -use PHPStan\Type\NullType; -use PHPStan\Type\StringAlwaysAcceptingObjectWithToStringType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; -use PHPStan\Type\UnionType; use function array_key_exists; use function array_map; use function strtolower; @@ -96,7 +88,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $variants[] = new FunctionVariantWithPhpDocs( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($lowerCasedFunctionName, $phpDoc): NativeParameterWithPhpDocsReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc): NativeParameterWithPhpDocsReflection { $type = $parameterSignature->getType(); $phpDocType = null; @@ -106,40 +98,6 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $phpDocType = $phpDocParam->getType(); } } - if ( - $parameterSignature->getName() === 'values' - && ( - $lowerCasedFunctionName === 'printf' - || $lowerCasedFunctionName === 'sprintf' - ) - ) { - $type = new UnionType([ - new StringAlwaysAcceptingObjectWithToStringType(), - new IntegerType(), - new FloatType(), - new NullType(), - new BooleanType(), - ]); - } - - if ( - $parameterSignature->getName() === 'fields' - && $lowerCasedFunctionName === 'fputcsv' - ) { - $type = new ArrayType( - new UnionType([ - new StringType(), - new IntegerType(), - ]), - new UnionType([ - new StringAlwaysAcceptingObjectWithToStringType(), - new IntegerType(), - new FloatType(), - new NullType(), - new BooleanType(), - ]), - ); - } return new NativeParameterWithPhpDocsReflection( $parameterSignature->getName(), diff --git a/src/Rules/Cast/CastObjectToStringRule.php b/src/Rules/Cast/CastObjectToStringRule.php new file mode 100644 index 0000000000..8432d709f9 --- /dev/null +++ b/src/Rules/Cast/CastObjectToStringRule.php @@ -0,0 +1,68 @@ + + */ +class CastObjectToStringRule implements Rule +{ + + public function __construct() + { + } + + public function getNodeType(): string + { + return Node\Expr\Cast::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof Node\Expr\Cast\String_) { + return []; + } + + $type = $scope->getType($node->expr); + if ($type->toString() instanceof ErrorType) { + return []; + } + + $objectType = TypeTraverser::map($type, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if (!(new ObjectWithoutClassType())->isSuperTypeOf($type)->yes()) { + return new NeverType(); + } + + return $type; + }); + + if ($objectType->hasMethod('__toString')->maybe()) { + return [ + RuleErrorBuilder::message(sprintf( + 'Casting %s to string might result in an error.', + $type->describe(VerbosityLevel::value()), + ))->line($node->getLine())->build(), + ]; + } + + return []; + } + +} diff --git a/src/Testing/TestCaseSourceLocatorFactory.php b/src/Testing/TestCaseSourceLocatorFactory.php index faeaaea61b..f4031d2a08 100644 --- a/src/Testing/TestCaseSourceLocatorFactory.php +++ b/src/Testing/TestCaseSourceLocatorFactory.php @@ -36,9 +36,18 @@ public function __construct( public function create(): SourceLocator { + $locators = []; + + $astLocator = new Locator($this->phpParser); + $astPhp8Locator = new Locator($this->php8Parser); + + $locators[] = new PhpInternalSourceLocator($astPhp8Locator, $this->phpstormStubsSourceStubber); + $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); + $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); + $classLoaders = ClassLoader::getRegisteredLoaders(); $classLoaderReflection = new ReflectionClass(ClassLoader::class); - $locators = []; if ($classLoaderReflection->hasProperty('vendorDir')) { $vendorDirProperty = $classLoaderReflection->getProperty('vendorDir'); $vendorDirProperty->setAccessible(true); @@ -56,14 +65,6 @@ public function create(): SourceLocator } } - $astLocator = new Locator($this->phpParser); - $astPhp8Locator = new Locator($this->php8Parser); - - $locators[] = new PhpInternalSourceLocator($astPhp8Locator, $this->phpstormStubsSourceStubber); - $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); - $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); - $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); - return new MemoizingSourceLocator(new AggregateSourceLocator($locators)); } diff --git a/src/Type/Accessory/HasMethodType.php b/src/Type/Accessory/HasMethodType.php index eea14a2cc5..5ac8471827 100644 --- a/src/Type/Accessory/HasMethodType.php +++ b/src/Type/Accessory/HasMethodType.php @@ -41,14 +41,18 @@ public function getReferencedClasses(): array return []; } - private function getCanonicalMethodName(): string + public function getCanonicalMethodName(): string { return strtolower($this->methodName); } public function accepts(Type $type, bool $strictTypes): TrinaryLogic { - return TrinaryLogic::createFromBoolean($this->equals($type)); + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + return $type->hasMethod($this->methodName); } public function isSuperTypeOf(Type $type): TrinaryLogic diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 791a5129cc..d8ea80a25b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -20,6 +20,7 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\AccessoryType; +use PHPStan\Type\Accessory\HasMethodType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; @@ -238,6 +239,12 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) continue; } + if ($type instanceof HasMethodType && $type->getCanonicalMethodName() === '__tostring') { + $typesToDescribe[] = $type; + $skipTypeNames[] = 'object'; + continue; + } + if ($skipAccessoryTypes) { continue; } @@ -255,6 +262,17 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) continue; } $typeDescription = $type->describe($level); + + if ($typeDescription === 'object' && in_array('object', $skipTypeNames, true)) { + foreach ($typesToDescribe as $j => $typeToDescribe) { + if ($typeToDescribe instanceof HasMethodType && $typeToDescribe->getCanonicalMethodName() === '__tostring') { + $describedTypes[] = 'stringable-object'; + unset($typesToDescribe[$j]); + continue 2; + } + } + } + if ( substr($typeDescription, 0, strlen('array<')) === 'array<' && in_array('array', $skipTypeNames, true) diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 1498960929..cf4aeca9ff 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -16,8 +16,10 @@ use PHPStan\Type\IntersectionType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\Type; use PHPStan\Type\UnionType; use function count; +use function strtolower; class MethodExistsTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension { @@ -62,10 +64,7 @@ public function specifyTypes( return $this->typeSpecifier->create( $node->getArgs()[0]->value, new UnionType([ - new IntersectionType([ - new ObjectWithoutClassType(), - new HasMethodType($methodNameType->getValue()), - ]), + $this->resolveObjectMethodType($methodNameType->getValue()), new ClassStringType(), ]), $context, @@ -74,4 +73,20 @@ public function specifyTypes( ); } + private function resolveObjectMethodType(string $methodName): Type + { + if (strtolower($methodName) === '__tostring') { + $stringableType = new ObjectType('Stringable'); + $classReflection = $stringableType->getClassReflection(); + if ($classReflection !== null && $classReflection->isBuiltin()) { + return $stringableType; + } + } + + return new IntersectionType([ + new ObjectWithoutClassType(), + new HasMethodType($methodName), + ]); + } + } diff --git a/src/Type/StringAlwaysAcceptingObjectWithToStringType.php b/src/Type/StringAlwaysAcceptingObjectWithToStringType.php deleted file mode 100644 index fbfd0b9b99..0000000000 --- a/src/Type/StringAlwaysAcceptingObjectWithToStringType.php +++ /dev/null @@ -1,28 +0,0 @@ -hasClass($type->getClassName())) { - return TrinaryLogic::createNo(); - } - - $typeClass = $reflectionProvider->getClass($type->getClassName()); - return TrinaryLogic::createFromBoolean( - $typeClass->hasNativeMethod('__toString'), - ); - } - - return parent::accepts($type, $strictTypes); - } - -} diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index ef03074841..a6d2a51cf8 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -1095,6 +1095,12 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7519.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-8087.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5785.php'); + + if (PHP_VERSION_ID>= 80000) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/stringable-object-php8.php'); + } else { + yield from $this->gatherAssertTypes(__DIR__ . '/data/stringable-object-php7.php'); + } } /** diff --git a/tests/PHPStan/Analyser/data/stringable-object-php7.php b/tests/PHPStan/Analyser/data/stringable-object-php7.php new file mode 100644 index 0000000000..394c42a726 --- /dev/null +++ b/tests/PHPStan/Analyser/data/stringable-object-php7.php @@ -0,0 +1,29 @@ +analyse([__DIR__ . '/data/new-out-of-phpstan.php'], [ [ 'Creating new PHPStan\Type\FileTypeMapper is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', - 18, + 17, $tip, ], [ 'Creating new PHPStan\DependencyInjection\NeonAdapter is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', - 19, - $tip, - ], - [ - 'Creating new PHPStan\Type\StringAlwaysAcceptingObjectWithToStringType is not covered by backward compatibility promise. The class might change in a minor PHPStan version.', - 20, + 18, $tip, ], ]); diff --git a/tests/PHPStan/Rules/Api/data/new-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/new-out-of-phpstan.php index fd2fd0f6e2..65dc117c22 100644 --- a/tests/PHPStan/Rules/Api/data/new-out-of-phpstan.php +++ b/tests/PHPStan/Rules/Api/data/new-out-of-phpstan.php @@ -5,7 +5,6 @@ use PHPStan\DependencyInjection\NeonAdapter; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\IntegerType; -use PHPStan\Type\StringAlwaysAcceptingObjectWithToStringType; class Foo { @@ -17,7 +16,6 @@ public function doFoo(): void new IntegerType(); new FileTypeMapper(); // error - has constructor new NeonAdapter(); // error - does not have a constructor - new StringAlwaysAcceptingObjectWithToStringType(); // error - constructor is inherited } } diff --git a/tests/PHPStan/Rules/Cast/CastObjectToStringRuleTest.php b/tests/PHPStan/Rules/Cast/CastObjectToStringRuleTest.php new file mode 100644 index 0000000000..0fcb4f0434 --- /dev/null +++ b/tests/PHPStan/Rules/Cast/CastObjectToStringRuleTest.php @@ -0,0 +1,29 @@ + + */ +class CastObjectToStringRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new CastObjectToStringRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/cast-object-to-string.php'], [ + [ + 'Casting object to string might result in an error.', + 11, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Cast/data/cast-object-to-string.php b/tests/PHPStan/Rules/Cast/data/cast-object-to-string.php new file mode 100644 index 0000000000..263ed1928b --- /dev/null +++ b/tests/PHPStan/Rules/Cast/data/cast-object-to-string.php @@ -0,0 +1,14 @@ += 80000 ? 'bool|float|int|string|Stringable|null' : 'bool|float|int|object|string|null'; + $this->analyse([__DIR__ . '/data/unpack-operator.php'], [ [ - 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, array given.', + sprintf('Parameter #2 ...$values of function sprintf expects %s, array given.', $expectedType), 18, ], [ - 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, array given.', + sprintf('Parameter #2 ...$values of function sprintf expects %s, array given.', $expectedType), 19, ], [ - 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, UnpackOperator\Foo given.', + sprintf('Parameter #2 ...$values of function sprintf expects %s, UnpackOperator\Foo given.', $expectedType), 22, ], [ - 'Parameter #2 ...$values of function printf expects bool|float|int|string|null, UnpackOperator\Foo given.', + sprintf('Parameter #2 ...$values of function printf expects %s, UnpackOperator\Foo given.', $expectedType), 24, ], ]); @@ -412,9 +414,11 @@ public function testUnpackOperator(): void public function testFputCsv(): void { + $expectedType = PHP_VERSION_ID>= 80000 ? 'bool|float|int|string|Stringable|null' : 'bool|float|int|object|string|null'; + $this->analyse([__DIR__ . '/data/fputcsv-fields-parameter.php'], [ [ - 'Parameter #2 $fields of function fputcsv expects array, array given.', + sprintf('Parameter #2 $fields of function fputcsv expects array, array given.', $expectedType), 35, ], ]); diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index e91065e062..1c9f0e31d1 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -2646,7 +2646,7 @@ public function dataIntersect(): iterable new HasMethodType('__toString'), ], IntersectionType::class, - 'object&hasMethod(__toString)', + 'stringable-object', ], [ [ @@ -2665,7 +2665,7 @@ public function dataIntersect(): iterable new HasMethodType('__toString'), ], IntersectionType::class, - 'object&hasMethod(__toString)', + 'stringable-object', ], [ [ diff --git a/tests/PHPStan/Type/UnionTypeTest.php b/tests/PHPStan/Type/UnionTypeTest.php index b26e9a00ad..070b032f84 100644 --- a/tests/PHPStan/Type/UnionTypeTest.php +++ b/tests/PHPStan/Type/UnionTypeTest.php @@ -133,7 +133,6 @@ public function dataSelfCompare(): Iterator yield [new ResourceType()]; yield [new StaticType($reflectionProvider->getClass('Foo'))]; yield [new StrictMixedType()]; - yield [new StringAlwaysAcceptingObjectWithToStringType()]; yield [$stringType]; yield [TemplateTypeFactory::create($templateTypeScope, 'T', null, TemplateTypeVariance::createInvariant())]; yield [TemplateTypeFactory::create($templateTypeScope, 'T', new ObjectType('Foo'), TemplateTypeVariance::createInvariant())];

AltStyle によって変換されたページ (->オリジナル) /