|
4 | 4 |
|
5 | 5 | use Nette\Utils\Strings;
|
6 | 6 | use PHPStan\Php\PhpVersion;
|
| 7 | +use PHPStan\Type\ErrorType; |
| 8 | +use PHPStan\Type\IntegerType; |
| 9 | +use PHPStan\Type\Type; |
7 | 10 | use function array_filter;
|
| 11 | +use function array_flip; |
| 12 | +use function array_keys; |
| 13 | +use function array_map; |
| 14 | +use function array_reduce; |
8 | 15 | use function count;
|
9 | 16 | use function max;
|
| 17 | +use function sort; |
10 | 18 | use function sprintf;
|
11 | 19 | use function strlen;
|
| 20 | +use function usort; |
12 | 21 | use const PREG_SET_ORDER;
|
13 | 22 |
|
| 23 | +/** @phpstan-type AcceptingTypeString 'strict-int'|'int'|'float'|'string'|'mixed' */ |
14 | 24 | final class PrintfHelper
|
15 | 25 | {
|
16 | 26 |
|
| 27 | + private const PRINTF_SPECIFIER_PATTERN = '(?<specifier>[bs%s]|l?[cdeEgfFGouxX])'; |
| 28 | + |
17 | 29 | public function __construct(private PhpVersion $phpVersion)
|
18 | 30 | {
|
19 | 31 | }
|
20 | 32 |
|
21 | 33 | public function getPrintfPlaceholdersCount(string $format): int
|
22 | 34 | {
|
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; |
24 | 89 | }
|
25 | 90 |
|
26 | 91 | public function getScanfPlaceholdersCount(string $format): int
|
27 | 92 | {
|
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 | + }; |
29 | 170 | }
|
30 | 171 |
|
31 | 172 | private function getPlaceholdersCount(string $specifiersPattern, string $format): int
|
|
0 commit comments