|
5 | 5 | use Nette\Utils\Strings; |
6 | 6 | use PHPStan\DependencyInjection\AutowiredService; |
7 | 7 | use PHPStan\Php\PhpVersion; |
| 8 | +use PHPStan\Type\ErrorType; |
| 9 | +use PHPStan\Type\IntegerType; |
| 10 | +use PHPStan\Type\Type; |
8 | 11 | use function array_filter; |
| 12 | +use function array_flip; |
| 13 | +use function array_keys; |
| 14 | +use function array_map; |
| 15 | +use function array_reduce; |
9 | 16 | use function count; |
10 | 17 | use function max; |
| 18 | +use function sort; |
11 | 19 | use function sprintf; |
12 | 20 | use function strlen; |
| 21 | +use function usort; |
13 | 22 | use const PREG_SET_ORDER; |
14 | 23 |
|
| 24 | +/** @phpstan-type AcceptingTypeString 'strict-int'|'int'|'float'|'string'|'mixed' */ |
15 | 25 | #[AutowiredService] |
16 | 26 | final class PrintfHelper |
17 | 27 | { |
18 | 28 |
|
| 29 | + private const PRINTF_SPECIFIER_PATTERN = '(?<specifier>[bs%s]|l?[cdeEgfFGouxX])'; |
| 30 | + |
19 | 31 | public function __construct(private PhpVersion $phpVersion) |
20 | 32 | { |
21 | 33 | } |
22 | 34 |
|
23 | 35 | public function getPrintfPlaceholdersCount(string $format): int |
24 | 36 | { |
25 | | - return $this->getPlaceholdersCount('(?:[bs%s]|l?[cdeEgfFGouxX])', $format); |
| 37 | + return $this->getPlaceholdersCount(self::PRINTF_SPECIFIER_PATTERN, $format); |
| 38 | + } |
| 39 | + |
| 40 | + /** @return array<int, array{string, callable(Type): bool}> position => [type name, matches callback] */ |
| 41 | + public function getPrintfPlaceholderAcceptingTypes(string $format): array |
| 42 | + { |
| 43 | + $placeholders = $this->parsePlaceholders(self::PRINTF_SPECIFIER_PATTERN, $format); |
| 44 | + $result = []; |
| 45 | + // int can go into float, string and mixed as well. |
| 46 | + // float can't go into int, but it can go to string/mixed. |
| 47 | + // string can go into mixed, but not into int/float. |
| 48 | + // mixed can only go into mixed. |
| 49 | + $typeSequenceMap = array_flip(['int', 'float', 'string', 'mixed']); |
| 50 | + |
| 51 | + foreach ($placeholders as $position => $types) { |
| 52 | + sort($types); |
| 53 | + $typeNames = array_map( |
| 54 | + static fn (string $t) => $t === 'strict-int' |
| 55 | + ? 'int' |
| 56 | + : $t, |
| 57 | + $types, |
| 58 | + ); |
| 59 | + $typeName = array_reduce( |
| 60 | + $typeNames, |
| 61 | + static fn (string $carry, string $type) => $typeSequenceMap[$carry] < $typeSequenceMap[$type] |
| 62 | + ? $carry |
| 63 | + : $type, |
| 64 | + 'mixed', |
| 65 | + ); |
| 66 | + $result[$position] = [ |
| 67 | + $typeName, |
| 68 | + static function (Type $t) use ($types): bool { |
| 69 | + foreach ($types as $acceptingType) { |
| 70 | + $subresult = match ($acceptingType) { |
| 71 | + 'strict-int' => (new IntegerType())->accepts($t, true)->yes(), |
| 72 | + // This allows float, constant non-numeric string, ... |
| 73 | + 'int' => ! $t->toInteger() instanceof ErrorType, |
| 74 | + 'float' => ! $t->toFloat() instanceof ErrorType, |
| 75 | + // The function signature already limits the parameters to stringable types, so there's |
| 76 | + // no point in checking it again here. |
| 77 | + 'string', 'mixed' => true, |
| 78 | + }; |
| 79 | + |
| 80 | + if (!$subresult) { |
| 81 | + return false; |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + return true; |
| 86 | + }, |
| 87 | + ]; |
| 88 | + } |
| 89 | + |
| 90 | + return $result; |
26 | 91 | } |
27 | 92 |
|
28 | 93 | public function getScanfPlaceholdersCount(string $format): int |
29 | 94 | { |
30 | | - return $this->getPlaceholdersCount('(?:[cdDeEfinosuxX%s]|\[[^\]]+\])', $format); |
| 95 | + return $this->getPlaceholdersCount('(?<specifier>[cdDeEfinosuxX%s]|\[[^\]]+\])', $format); |
| 96 | + } |
| 97 | + |
| 98 | + /** @phpstan-return array<int, non-empty-list<AcceptingTypeString>> position => type */ |
| 99 | + private function parsePlaceholders(string $specifiersPattern, string $format): array |
| 100 | + { |
| 101 | + $addSpecifier = ''; |
| 102 | + if ($this->phpVersion->supportsHhPrintfSpecifier()) { |
| 103 | + $addSpecifier .= 'hH'; |
| 104 | + } |
| 105 | + |
| 106 | + $specifiers = sprintf($specifiersPattern, $addSpecifier); |
| 107 | + |
| 108 | + $pattern = '~(?<before>%*)%(?:(?<position>\d+)\$)?[-+]?(?:[ 0]|(?:\'[^%]))?(?<width>\*)?-?\d*(?:\.(?:\d+|(?<precision>\*))?)?' . $specifiers . '~'; |
| 109 | + |
| 110 | + $matches = Strings::matchAll($format, $pattern, PREG_SET_ORDER); |
| 111 | + |
| 112 | + if (count($matches) === 0) { |
| 113 | + return []; |
| 114 | + } |
| 115 | + |
| 116 | + $placeholders = array_filter($matches, static fn (array $match): bool => strlen($match['before']) % 2 === 0); |
| 117 | + |
| 118 | + $result = []; |
| 119 | + $positionToIdxMap = []; |
| 120 | + $positionalPlaceholders = []; |
| 121 | + $idx = $position = 0; |
| 122 | + |
| 123 | + foreach ($placeholders as $placeholder) { |
| 124 | + if (isset($placeholder['width']) && $placeholder['width'] !== '') { |
| 125 | + $result[$idx] = ['strict-int' => 1]; |
| 126 | + $positionToIdxMap[$position++] = $idx++; |
| 127 | + } |
| 128 | + |
| 129 | + if (isset($placeholder['precision']) && $placeholder['precision'] !== '') { |
| 130 | + $result[$idx] = ['strict-int' => 1]; |
| 131 | + $positionToIdxMap[$position++] = $idx++; |
| 132 | + } |
| 133 | + |
| 134 | + if (isset($placeholder['position']) && $placeholder['position'] !== '') { |
| 135 | + // It may reference future position, so we have to process them later. |
| 136 | + $positionalPlaceholders[] = $placeholder; |
| 137 | + continue; |
| 138 | + } |
| 139 | + |
| 140 | + $position++; |
| 141 | + $positionToIdxMap[$position] = $idx; |
| 142 | + $result[$idx++][$this->getAcceptingTypeBySpecifier($placeholder['specifier'] ?? '')] = 1; |
| 143 | + } |
| 144 | + |
| 145 | + usort( |
| 146 | + $positionalPlaceholders, |
| 147 | + static fn (array $a, array $b) => (int) $a['position'] <=> (int) $b['position'], |
| 148 | + ); |
| 149 | + |
| 150 | + foreach ($positionalPlaceholders as $placeholder) { |
| 151 | + $idx = $positionToIdxMap[$placeholder['position']] ?? null; |
| 152 | + |
| 153 | + if ($idx === null) { |
| 154 | + continue; |
| 155 | + } |
| 156 | + |
| 157 | + $result[$idx][$this->getAcceptingTypeBySpecifier($placeholder['specifier'] ?? '')] = 1; |
| 158 | + } |
| 159 | + |
| 160 | + return array_map(static fn (array $a) => array_keys($a), $result); |
| 161 | + } |
| 162 | + |
| 163 | + /** @phpstan-return 'string'|'int'|'float'|'mixed' */ |
| 164 | + private function getAcceptingTypeBySpecifier(string $specifier): string |
| 165 | + { |
| 166 | + return match ($specifier) { |
| 167 | + 's' => 'string', |
| 168 | + 'd', 'u', 'c', 'o', 'x', 'X', 'b' => 'int', |
| 169 | + 'e', 'E', 'f', 'F', 'g', 'G', 'h', 'H' => 'float', |
| 170 | + default => 'mixed', |
| 171 | + }; |
31 | 172 | } |
32 | 173 |
|
33 | 174 | private function getPlaceholdersCount(string $specifiersPattern, string $format): int |
|
0 commit comments