Skip to content

Commit 258ee7b

Browse files
committed
Bleeding edge - check printf parameter types
1 parent ea7072c commit 258ee7b

File tree

8 files changed

+447
-2
lines changed

8 files changed

+447
-2
lines changed

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ parameters:
66
stricterFunctionMap: true
77
reportPreciseLineForUnusedFunctionParameter: true
88
internalTag: true
9+
checkPrintfParameterTypes: true

conf/config.level5.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ parameters:
88
conditionalTags:
99
PHPStan\Rules\Functions\ParameterCastableToNumberRule:
1010
phpstan.rules.rule: %featureToggles.checkParameterCastableToNumberFunctions%
11+
PHPStan\Rules\Functions\PrintfParameterTypeRule:
12+
phpstan.rules.rule: %featureToggles.checkPrintfParameterTypes%
1113

1214
rules:
1315
- PHPStan\Rules\DateTimeInstantiationRule
@@ -42,3 +44,5 @@ services:
4244
- phpstan.rules.rule
4345
-
4446
class: PHPStan\Rules\Functions\ParameterCastableToNumberRule
47+
-
48+
class: PHPStan\Rules\Functions\PrintfParameterTypeRule

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ parameters:
2727
stricterFunctionMap: false
2828
reportPreciseLineForUnusedFunctionParameter: false
2929
internalTag: false
30+
checkPrintfParameterTypes: false
3031
fileExtensions:
3132
- php
3233
checkAdvancedIsset: false

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ parametersSchema:
3333
stricterFunctionMap: bool()
3434
reportPreciseLineForUnusedFunctionParameter: bool()
3535
internalTag: bool()
36+
checkPrintfParameterTypes: bool()
3637
])
3738
fileExtensions: listOf(string())
3839
checkAdvancedIsset: bool()

src/Rules/Functions/PrintfHelper.php

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,169 @@
44

55
use Nette\Utils\Strings;
66
use PHPStan\Php\PhpVersion;
7+
use PHPStan\Type\ErrorType;
8+
use PHPStan\Type\IntegerType;
9+
use PHPStan\Type\Type;
710
use function array_filter;
11+
use function array_flip;
12+
use function array_keys;
13+
use function array_map;
14+
use function array_reduce;
815
use function count;
916
use function max;
17+
use function sort;
1018
use function sprintf;
1119
use function strlen;
20+
use function usort;
1221
use const PREG_SET_ORDER;
1322

23+
/** @phpstan-type AcceptingTypeString 'strict-int'|'int'|'float'|'string'|'mixed' */
1424
final class PrintfHelper
1525
{
1626

27+
private const PRINTF_SPECIFIER_PATTERN = '(?<specifier>[bs%s]|l?[cdeEgfFGouxX])';
28+
1729
public function __construct(private PhpVersion $phpVersion)
1830
{
1931
}
2032

2133
public function getPrintfPlaceholdersCount(string $format): int
2234
{
23-
return $this->getPlaceholdersCount('(?:[bs%s]|l?[cdeEgfFGouxX])', $format);
35+
return $this->getPlaceholdersCount(self::PRINTF_SPECIFIER_PATTERN, $format);
36+
}
37+
38+
/** @return array<int, array{string, callable(Type): bool}> position => [type name, matches callback] */
39+
public function getPrintfPlaceholderAcceptingTypes(string $format): array
40+
{
41+
$placeholders = $this->parsePlaceholders(self::PRINTF_SPECIFIER_PATTERN, $format);
42+
$result = [];
43+
// int can go into float, string and mixed as well.
44+
// float can't go into int, but it can go to string/mixed.
45+
// string can go into mixed, but not into int/float.
46+
// mixed can only go into mixed.
47+
$typeSequenceMap = array_flip(['int', 'float', 'string', 'mixed']);
48+
49+
foreach ($placeholders as $position => $types) {
50+
sort($types);
51+
$typeNames = array_map(
52+
static fn (string $t) => $t === 'strict-int'
53+
? 'int'
54+
: $t,
55+
$types,
56+
);
57+
$typeName = array_reduce(
58+
$typeNames,
59+
static fn (string $carry, string $type) => $typeSequenceMap[$carry] < $typeSequenceMap[$type]
60+
? $carry
61+
: $type,
62+
'mixed',
63+
);
64+
$result[$position] = [
65+
$typeName,
66+
static function (Type $t) use ($types): bool {
67+
foreach ($types as $acceptingType) {
68+
$subresult = match ($acceptingType) {
69+
'strict-int' => (new IntegerType())->accepts($t, true)->yes(),
70+
// This allows float, constant non-numeric string, ...
71+
'int' => ! $t->toInteger() instanceof ErrorType,
72+
'float' => ! $t->toFloat() instanceof ErrorType,
73+
// The function signature already limits the parameters to stringable types, so there's
74+
// no point in checking it again here.
75+
'string', 'mixed' => true,
76+
};
77+
78+
if (!$subresult) {
79+
return false;
80+
}
81+
}
82+
83+
return true;
84+
},
85+
];
86+
}
87+
88+
return $result;
2489
}
2590

2691
public function getScanfPlaceholdersCount(string $format): int
2792
{
28-
return $this->getPlaceholdersCount('(?:[cdDeEfinosuxX%s]|\[[^\]]+\])', $format);
93+
return $this->getPlaceholdersCount('(?<specifier>[cdDeEfinosuxX%s]|\[[^\]]+\])', $format);
94+
}
95+
96+
/** @phpstan-return array<int, non-empty-list<AcceptingTypeString>> position => type */
97+
private function parsePlaceholders(string $specifiersPattern, string $format): array
98+
{
99+
$addSpecifier = '';
100+
if ($this->phpVersion->supportsHhPrintfSpecifier()) {
101+
$addSpecifier .= 'hH';
102+
}
103+
104+
$specifiers = sprintf($specifiersPattern, $addSpecifier);
105+
106+
$pattern = '~(?<before>%*)%(?:(?<position>\d+)\$)?[-+]?(?:[ 0]|(?:\'[^%]))?(?<width>\*)?-?\d*(?:\.(?:\d+|(?<precision>\*))?)?' . $specifiers . '~';
107+
108+
$matches = Strings::matchAll($format, $pattern, PREG_SET_ORDER);
109+
110+
if (count($matches) === 0) {
111+
return [];
112+
}
113+
114+
$placeholders = array_filter($matches, static fn (array $match): bool => strlen($match['before']) % 2 === 0);
115+
116+
$result = [];
117+
$positionToIdxMap = [];
118+
$positionalPlaceholders = [];
119+
$idx = $position = 0;
120+
121+
foreach ($placeholders as $placeholder) {
122+
if (isset($placeholder['width']) && $placeholder['width'] !== '') {
123+
$result[$idx] = ['strict-int' => 1];
124+
$positionToIdxMap[$position++] = $idx++;
125+
}
126+
127+
if (isset($placeholder['precision']) && $placeholder['precision'] !== '') {
128+
$result[$idx] = ['strict-int' => 1];
129+
$positionToIdxMap[$position++] = $idx++;
130+
}
131+
132+
if (isset($placeholder['position']) && $placeholder['position'] !== '') {
133+
// It may reference future position, so we have to process them later.
134+
$positionalPlaceholders[] = $placeholder;
135+
continue;
136+
}
137+
138+
$position++;
139+
$positionToIdxMap[$position] = $idx;
140+
$result[$idx++][$this->getAcceptingTypeBySpecifier($placeholder['specifier'] ?? '')] = 1;
141+
}
142+
143+
usort(
144+
$positionalPlaceholders,
145+
static fn (array $a, array $b) => (int) $a['position'] <=> (int) $b['position'],
146+
);
147+
148+
foreach ($positionalPlaceholders as $placeholder) {
149+
$idx = $positionToIdxMap[$placeholder['position']] ?? null;
150+
151+
if ($idx === null) {
152+
continue;
153+
}
154+
155+
$result[$idx][$this->getAcceptingTypeBySpecifier($placeholder['specifier'] ?? '')] = 1;
156+
}
157+
158+
return array_map(static fn (array $a) => array_keys($a), $result);
159+
}
160+
161+
/** @phpstan-return 'string'|'int'|'float'|'mixed' */
162+
private function getAcceptingTypeBySpecifier(string $specifier): string
163+
{
164+
return match ($specifier) {
165+
's' => 'string',
166+
'd', 'u', 'c', 'o', 'x', 'X', 'b' => 'int',
167+
'e', 'E', 'f', 'F', 'g', 'G', 'h', 'H' => 'float',
168+
default => 'mixed',
169+
};
29170
}
30171

31172
private function getPlaceholdersCount(string $specifiersPattern, string $format): int
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\ReflectionProvider;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPStan\Rules\RuleLevelHelper;
11+
use PHPStan\Type\BooleanType;
12+
use PHPStan\Type\ErrorType;
13+
use PHPStan\Type\FloatType;
14+
use PHPStan\Type\IntegerType;
15+
use PHPStan\Type\NullType;
16+
use PHPStan\Type\StringAlwaysAcceptingObjectWithToStringType;
17+
use PHPStan\Type\TypeCombinator;
18+
use PHPStan\Type\VerbosityLevel;
19+
use function array_key_exists;
20+
use function count;
21+
use function sprintf;
22+
23+
/**
24+
* @implements Rule<Node\Expr\FuncCall>
25+
*/
26+
final class PrintfParameterTypeRule implements Rule
27+
{
28+
29+
private const FORMAT_ARGUMENT_POSITIONS = [
30+
'printf' => 0,
31+
'sprintf' => 0,
32+
'fprintf' => 1,
33+
];
34+
private const MINIMUM_NUMBER_OF_ARGUMENTS = [
35+
'printf' => 1,
36+
'sprintf' => 1,
37+
'fprintf' => 2,
38+
];
39+
40+
public function __construct(
41+
private PrintfHelper $printfHelper,
42+
private ReflectionProvider $reflectionProvider,
43+
private RuleLevelHelper $ruleLevelHelper,
44+
)
45+
{
46+
}
47+
48+
public function getNodeType(): string
49+
{
50+
return Node\Expr\FuncCall::class;
51+
}
52+
53+
public function processNode(Node $node, Scope $scope): array
54+
{
55+
if (!($node->name instanceof Node\Name)) {
56+
return [];
57+
}
58+
59+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
60+
return [];
61+
}
62+
63+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
64+
$name = $functionReflection->getName();
65+
if (!array_key_exists($name, self::FORMAT_ARGUMENT_POSITIONS)) {
66+
return [];
67+
}
68+
69+
$formatArgumentPosition = self::FORMAT_ARGUMENT_POSITIONS[$name];
70+
71+
$args = $node->getArgs();
72+
foreach ($args as $arg) {
73+
if ($arg->unpack) {
74+
return [];
75+
}
76+
}
77+
$argsCount = count($args);
78+
if ($argsCount < self::MINIMUM_NUMBER_OF_ARGUMENTS[$name]) {
79+
return []; // caught by CallToFunctionParametersRule
80+
}
81+
82+
$formatArgType = $scope->getType($args[$formatArgumentPosition]->value);
83+
$formatArgTypeStrings = $formatArgType->getConstantStrings();
84+
85+
// Let's start simple for now.
86+
if (count($formatArgTypeStrings) !== 1) {
87+
return [];
88+
}
89+
90+
$formatString = $formatArgTypeStrings[0];
91+
$format = $formatString->getValue();
92+
$acceptingTypes = $this->printfHelper->getPrintfPlaceholderAcceptingTypes($format);
93+
$errors = [];
94+
$typeAllowedByCallToFunctionParametersRule = TypeCombinator::union(
95+
new StringAlwaysAcceptingObjectWithToStringType(),
96+
new IntegerType(),
97+
new FloatType(),
98+
new BooleanType(),
99+
new NullType(),
100+
);
101+
102+
for ($i = $formatArgumentPosition + 1, $j = 0; $i < $argsCount; $i++, $j++) {
103+
// Some arguments may be skipped entirely.
104+
if (! array_key_exists($j, $acceptingTypes)) {
105+
continue;
106+
}
107+
108+
[$acceptingName, $acceptingCb] = $acceptingTypes[$j];
109+
$argType = $this->ruleLevelHelper->findTypeToCheck(
110+
$scope,
111+
$args[$i]->value,
112+
'',
113+
$acceptingCb,
114+
)->getType();
115+
116+
if ($argType instanceof ErrorType || $acceptingCb($argType)) {
117+
continue;
118+
}
119+
120+
// This is already reported by CallToFunctionParametersRule
121+
if (
122+
!$this->ruleLevelHelper->accepts(
123+
$typeAllowedByCallToFunctionParametersRule,
124+
$argType,
125+
$scope->isDeclareStrictTypes(),
126+
)->result
127+
) {
128+
continue;
129+
}
130+
131+
$errors[] = RuleErrorBuilder::message(
132+
sprintf(
133+
'Placeholder #%d of function %s expects %s, %s given',
134+
$j + 1,
135+
$name,
136+
$acceptingName,
137+
$argType->describe(VerbosityLevel::typeOnly()),
138+
),
139+
)->identifier('argument.type')->build();
140+
}
141+
142+
return $errors;
143+
}
144+
145+
}

0 commit comments

Comments
 (0)