Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/Rules/AcceptedTypeDescriptionHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules;

use PHPStan\DependencyInjection\BleedingEdgeToggle;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\GeneralizePrecision;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\VerbosityLevel;
use function count;
use function strlen;

/**
* Describes the accepted (given, returned, assigned) type of an "X expected,
* Y given" error message. On bleeding edge, a description too long to read is
* replaced with one where literal values are widened to their general types
* (keeping what is known about them, like lowercase-string),
* and big array shapes and lists of a single value type are turned into
* general arrays, keeping the structure that usually tells where the type
* differs from the accepting one.
*/
final class AcceptedTypeDescriptionHelper
{

private const DESCRIPTION_LENGTH_LIMIT = 1000;

private const DESCRIBED_ARRAY_SHAPE_KEYS_LIMIT = 8;

public static function describe(Type $acceptingType, Type $acceptedType, VerbosityLevel $verbosityLevel): string
{
$description = $acceptedType->describe($verbosityLevel);
if (strlen($description) <= self::DESCRIPTION_LENGTH_LIMIT || !BleedingEdgeToggle::isBleedingEdge()) {
return $description;
}

$generalizedDescription = TypeTraverser::map($acceptedType, static function (Type $type, callable $traverse): Type {
if ($type->isConstantScalarValue()->yes()) {
return $type->generalize(GeneralizePrecision::moreSpecific());
}

$type = $traverse($type);
if (!$type->isConstantArray()->yes()) {
return $type;
}

$constantArrays = $type->getConstantArrays();
if (count($constantArrays) !== 1 || count($constantArrays[0]->getKeyTypes()) === 0) {
return $type;
}

if (
count($constantArrays[0]->getKeyTypes()) <= self::DESCRIBED_ARRAY_SHAPE_KEYS_LIMIT
&& !($type->isList()->yes() && self::hasSameValueTypes($constantArrays[0]->getValueTypes()))
) {
return $type;
}

$arrayType = new ArrayType($type->getIterableKeyType()->generalize(GeneralizePrecision::moreSpecific()), $type->getIterableValueType());
if ($type->isList()->yes()) {
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
}
if ($type->isIterableAtLeastOnce()->yes()) {
$arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
}

return $arrayType;
})->describe($verbosityLevel);

if (
strlen($generalizedDescription) >= strlen($description)
|| $generalizedDescription === $acceptingType->describe($verbosityLevel)
) {
return $description;
}

return $generalizedDescription;
}

/**
* @param array<Type> $valueTypes
*/
private static function hasSameValueTypes(array $valueTypes): bool
{
$firstValueType = null;
foreach ($valueTypes as $valueType) {
if ($firstValueType === null) {
$firstValueType = $valueType;
continue;
}
if (!$valueType->equals($firstValueType)) {
return false;
}
}

return true;
}

}
2 changes: 1 addition & 1 deletion src/Rules/FunctionCallParametersCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ public function check(
$wrongArgumentTypeMessage,
$this->describeParameter($parameter, $argumentName ?? $i + 1),
$parameterType->describe($verbosityLevel),
$argumentValueType->describe($verbosityLevel),
AcceptedTypeDescriptionHelper::describe($parameterType, $argumentValueType, $verbosityLevel),
))
->identifier('argument.type')
->line($argumentLine)
Expand Down
2 changes: 1 addition & 1 deletion src/Rules/FunctionReturnTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public function checkReturnType(
RuleErrorBuilder::message(sprintf(
$typeMismatchMessage,
$returnType->describe($verbosityLevel),
$returnValueType->describe($verbosityLevel),
AcceptedTypeDescriptionHelper::describe($returnType, $returnValueType, $verbosityLevel),
))
->line($returnNode->getStartLine())
->identifier('return.type')
Expand Down
6 changes: 6 additions & 0 deletions src/Rules/RuleErrorBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use PhpParser\Node;
use PHPStan\Analyser\Error;
use PHPStan\DependencyInjection\BleedingEdgeToggle;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\AcceptsReasonHelper;
use function array_map;
use function class_exists;
use function count;
Expand Down Expand Up @@ -244,6 +246,10 @@ public function discoveringSymbolsTip(): self
*/
public function acceptsReasonsTip(array $reasons): self
{
if (BleedingEdgeToggle::isBleedingEdge()) {
$reasons = AcceptsReasonHelper::forTip($reasons);
}

foreach ($reasons as $reason) {
$this->addTip($reason);
}
Expand Down
229 changes: 229 additions & 0 deletions src/Type/AcceptsReasonHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type;

use function array_key_exists;
use function array_map;
use function array_slice;
use function count;
use function implode;
use function preg_match;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;

/**
* Keeps the reasons of accepts() and isSuperTypeOf() readable when the
* compared types are big (bleeding edge).
*
* Reasons of nested arrays are offset paths
* (`Offset 'foo' › 0 › 1 (array{int, int}): reason`) instead of repeating
* the whole accepted type at every level. Only the innermost offset names
* its types, and the accepted one only when it is not an array shape -
* the reasons of an array shape already point into it. The same reason
* found at many offsets is reported once, listing the offsets.
*/
final class AcceptsReasonHelper
{

private const OFFSET_PREFIX = 'Offset ';

private const OFFSET_PATH_SEPARATOR = ' › ';

/**
* A path step as ConstantIntegerType and ConstantStringType describe
* themselves at VerbosityLevel::precise().
*/
private const OFFSET_PATTERN = '~\G(?:-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*")~';

private const LISTED_ITEMS_LIMIT = 3;

private const TIP_REASONS_LIMIT = 5;

private const SUBJECT_DESCRIPTION_LENGTH_LIMIT = 120;

public static function prependOffset(Type $offsetType, Type $offsetValueType, Type $acceptedValueType, VerbosityLevel $verbosity, string $reason): string
{
if (str_starts_with($reason, self::OFFSET_PREFIX)) {
return sprintf(
'Offset %s%s%s',
$offsetType->describe(VerbosityLevel::precise()),
self::OFFSET_PATH_SEPARATOR,
substr($reason, strlen(self::OFFSET_PREFIX)),
);
}

if ($acceptedValueType->isConstantArray()->yes()) {
return sprintf(
'Offset %s (%s): %s',
$offsetType->describe(VerbosityLevel::precise()),
$offsetValueType->describe($verbosity),
$reason,
);
}

return sprintf(
'Offset %s (%s) does not accept type %s: %s',
$offsetType->describe(VerbosityLevel::precise()),
$offsetValueType->describe($verbosity),
$acceptedValueType->describe($verbosity),
$reason,
);
}

/**
* The reasons as error tips: grouped by groupReasons(), and only the first
* few of many - the rest are typically the same problem in other members
* of a union.
*
* @param list<string> $reasons
* @return list<string>
*/
public static function forTip(array $reasons): array
{
$reasons = self::groupReasons($reasons);
$count = count($reasons);
if ($count <= self::TIP_REASONS_LIMIT) {
return $reasons;
}

$reasons = array_slice($reasons, 0, self::TIP_REASONS_LIMIT);
$reasons[] = sprintf('%d more reasons not shown.', $count - self::TIP_REASONS_LIMIT);

return $reasons;
}

/**
* Merges reasons that differ only in their offset path into one
* (`Offsets 'a' › 0, 'b' › 0 and 5 more (int): reason`), in the order
* they first appear.
*
* @param list<string> $reasons
* @return list<string>
*/
public static function groupReasons(array $reasons): array
{
/** @var array<string, non-empty-list<string>> $pathsByRest */
$pathsByRest = [];

/** @var list<array{string}|array{null, string}> $order */
$order = [];
foreach ($reasons as $reason) {
$split = self::splitOffsetPath($reason);
if ($split === null) {
$order[] = [$reason];
continue;
}

[$path, $rest] = $split;
if (array_key_exists($rest, $pathsByRest)) {
$pathsByRest[$rest][] = $path;
continue;
}

$pathsByRest[$rest] = [$path];
$order[] = [null, $rest];
}

$grouped = [];
foreach ($order as $item) {
if (count($item) === 1) {
$grouped[] = $item[0];
continue;
}

$rest = $item[1];
$paths = $pathsByRest[$rest];
if (count($paths) === 1) {
$grouped[] = self::OFFSET_PREFIX . $paths[0] . $rest;
continue;
}

$grouped[] = 'Offsets ' . self::listItems($paths) . $rest;
}

return $grouped;
}

/**
* The accepted type as the subject of a reason, or $fallback when its
* description is too long to repeat - the error message describes it already.
*/
public static function describeSubject(Type $type, VerbosityLevel $level, string $fallback): string
{
$description = $type->describe($level);
if (strlen($description) > self::SUBJECT_DESCRIPTION_LENGTH_LIMIT) {
return $fallback;
}

return $description;
}

/**
* @param non-empty-list<string> $propertyNames
*/
public static function missingProperties(string $subject, array $propertyNames, bool $certainly): string
{
return sprintf(
'%s %s have %s %s.',
$subject,
$certainly ? 'does not' : 'might not',
count($propertyNames) === 1 ? 'property' : 'properties',
self::listItems(array_map(static fn (string $propertyName): string => '$' . $propertyName, $propertyNames)),
);
}

/**
* `a`, `a and b`, `a, b and c`, `a, b, c and 2 more`
*
* @param non-empty-list<string> $items
*/
private static function listItems(array $items): string
{
$count = count($items);
if ($count === 1) {
return $items[0];
}

if ($count > self::LISTED_ITEMS_LIMIT) {
return sprintf(
'%s and %d more',
implode(', ', array_slice($items, 0, self::LISTED_ITEMS_LIMIT)),
$count - self::LISTED_ITEMS_LIMIT,
);
}

return sprintf('%s and %s', implode(', ', array_slice($items, 0, -1)), $items[$count - 1]);
}

/**
* @return array{string, string}|null the offset path and the rest of the reason
*/
private static function splitOffsetPath(string $reason): ?array
{
if (!str_starts_with($reason, self::OFFSET_PREFIX)) {
return null;
}

$position = strlen(self::OFFSET_PREFIX);
while (true) {
if (preg_match(self::OFFSET_PATTERN, $reason, $matches, offset: $position) !== 1) {
return null;
}
$position += strlen($matches[0]);
if (substr($reason, $position, strlen(self::OFFSET_PATH_SEPARATOR)) !== self::OFFSET_PATH_SEPARATOR) {
break;
}
$position += strlen(self::OFFSET_PATH_SEPARATOR);
}

$rest = substr($reason, $position);
if ($rest === '' || ($rest[0] !== ' ' && $rest[0] !== ':')) {
return null;
}

return [substr($reason, strlen(self::OFFSET_PREFIX), $position - strlen(self::OFFSET_PREFIX)), $rest];
}

}
8 changes: 8 additions & 0 deletions src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Type;

use PHPStan\DependencyInjection\BleedingEdgeToggle;
use PHPStan\Php\PhpVersion;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
Expand Down Expand Up @@ -131,10 +132,17 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
$result = AcceptsResult::createYes();
$thisKeyType = $this->keyType;
$itemType = $this->getItemType();
$bleedingEdge = BleedingEdgeToggle::isBleedingEdge();
foreach ($type->getKeyTypes() as $i => $keyType) {
$valueType = $type->getValueTypes()[$i];
$acceptsKey = $thisKeyType->accepts($keyType, $strictTypes);
$acceptsValue = $itemType->accepts($valueType, $strictTypes);
if ($bleedingEdge && count($acceptsValue->reasons) > 0) {
$verbosity = VerbosityLevel::getRecommendedLevelByType($itemType, $valueType);
$acceptsValue = $acceptsValue->decorateReasons(
static fn (string $reason) => AcceptsReasonHelper::prependOffset($keyType, $itemType, $valueType, $verbosity, $reason),
);
}
$result = $result->and($acceptsKey)->and($acceptsValue);
}

Expand Down
Loading
Loading