|
7 | 7 | use PHPStan\Analyser\Scope;
|
8 | 8 | use PHPStan\Reflection\ReflectionProvider;
|
9 | 9 | use PHPStan\Rules\Rule;
|
| 10 | +use PHPStan\Rules\RuleError; |
10 | 11 | use PHPStan\Rules\RuleErrorBuilder;
|
11 | 12 | use PHPStan\Type\ClosureType;
|
12 | 13 | use PHPStan\Type\Constant\ConstantArrayType;
|
13 | 14 | use PHPStan\Type\Constant\ConstantIntegerType;
|
14 | 15 | use PHPStan\Type\Constant\ConstantStringType;
|
| 16 | +use PHPStan\Type\Generic\GenericClassStringType; |
| 17 | +use PHPStan\Type\IntersectionType; |
15 | 18 | use PHPStan\Type\ObjectType;
|
| 19 | +use PHPStan\Type\ThisType; |
16 | 20 | use PHPStan\Type\Type;
|
17 | 21 | use PHPStan\Type\UnionType;
|
18 | 22 | use PHPStan\Type\VerbosityLevel;
|
@@ -42,94 +46,150 @@ public function processNode(Node $node, Scope $scope): array
|
42 | 46 | if (!$key instanceof Node\Scalar\String_) {
|
43 | 47 | return [];
|
44 | 48 | }
|
| 49 | + |
| 50 | + // @todo this should be 3 rules. |
45 | 51 | // @see https://www.drupal.org/node/2966725
|
46 |
| - $keysToCheck = ['#pre_render', '#post_render', '#lazy_builder', '#access_callback']; |
| 52 | + $keysToCheck = ['#pre_render', '#post_render', '#access_callback', '#lazy_builder']; |
47 | 53 | $keySearch = array_search($key->value, $keysToCheck, true);
|
48 | 54 | if ($keySearch === false) {
|
49 | 55 | return [];
|
50 | 56 | }
|
51 | 57 | $keyChecked = $keysToCheck[$keySearch];
|
52 | 58 |
|
53 | 59 | $value = $node->value;
|
54 |
| - if (!$value instanceof Node\Expr\Array_) { |
55 |
| - return [ |
56 |
| - RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked)) |
57 |
| - ->line($node->getLine())->build() |
58 |
| - ]; |
59 |
| - } |
60 |
| - if (count($value->items) === 0) { |
61 |
| - return []; |
| 60 | + |
| 61 | + $errors = []; |
| 62 | + |
| 63 | + // @todo Move into its own rule. |
| 64 | + if ($keyChecked === '#lazy_builder') { |
| 65 | + if (!$value instanceof Node\Expr\Array_) { |
| 66 | + return [ |
| 67 | + RuleErrorBuilder::message(sprintf('The "%s" expects a callable array with arguments.', $keyChecked)) |
| 68 | + ->line($node->getLine())->build() |
| 69 | + ]; |
| 70 | + } |
| 71 | + if (count($value->items) === 0) { |
| 72 | + return []; |
| 73 | + } |
| 74 | + if ($value->items[0] === null) { |
| 75 | + return []; |
| 76 | + } |
| 77 | + // @todo take $value->items[1] and validate parameters against the callback. |
| 78 | + $errors[] = $this->doProcessNode($value->items[0]->value, $scope, $keyChecked, 0); |
| 79 | + } elseif ($keyChecked === '#access_callback') { |
| 80 | + // @todo move into its own rule. |
| 81 | + $errors[] = $this->doProcessNode($value, $scope, $keyChecked, 0); |
| 82 | + } else { |
| 83 | + // @todo keep here. |
| 84 | + if (!$value instanceof Node\Expr\Array_) { |
| 85 | + return [ |
| 86 | + RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked)) |
| 87 | + ->line($node->getLine())->build() |
| 88 | + ]; |
| 89 | + } |
| 90 | + if (count($value->items) === 0) { |
| 91 | + return []; |
| 92 | + } |
| 93 | + foreach ($value->items as $pos => $item) { |
| 94 | + if (!$item instanceof Node\Expr\ArrayItem) { |
| 95 | + continue; |
| 96 | + } |
| 97 | + $errors[] = $this->doProcessNode($item->value, $scope, $keyChecked, $pos); |
| 98 | + } |
62 | 99 | }
|
| 100 | + return array_filter($errors); |
| 101 | + } |
63 | 102 |
|
| 103 | + private function doProcessNode(Node\Expr $node, Scope $scope, string $keyChecked, int $pos): ?RuleError |
| 104 | + { |
64 | 105 | $trustedCallbackType = new UnionType([
|
65 | 106 | new ObjectType('Drupal\Core\Security\TrustedCallbackInterface'),
|
66 | 107 | new ObjectType('Drupal\Core\Render\Element\RenderCallbackInterface'),
|
67 | 108 | ]);
|
68 |
| - $errors = []; |
69 |
| - foreach ($value->items as $pos => $item) { |
70 |
| - if (!$item instanceof Node\Expr\ArrayItem) { |
71 |
| - continue; |
| 109 | + |
| 110 | + $errorLine = $node->getLine(); |
| 111 | + $type = $this->getType($node, $scope); |
| 112 | + |
| 113 | + if ($type instanceof ConstantStringType) { |
| 114 | + if (!$type->isCallable()->yes()) { |
| 115 | + return RuleErrorBuilder::message( |
| 116 | + sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 117 | + )->line($errorLine)->build(); |
| 118 | + } |
| 119 | + // We can determine if the callback is callable through the type system. However, we cannot determine |
| 120 | + // if it is just a function or a static class call (MyClass::staticFunc). |
| 121 | + if ($this->reflectionProvider->hasFunction(new Node\Name($type->getValue()), null)) { |
| 122 | + return RuleErrorBuilder::message( |
| 123 | + sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 124 | + )->line($errorLine) |
| 125 | + ->tip('Change record: https://www.drupal.org/node/2966725.') |
| 126 | + ->build(); |
| 127 | + } |
| 128 | + } elseif ($type instanceof ConstantArrayType) { |
| 129 | + if (!$type->isCallable()->yes()) { |
| 130 | + return RuleErrorBuilder::message( |
| 131 | + sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 132 | + )->line($errorLine)->build(); |
| 133 | + } |
| 134 | + $typeAndMethodName = $type->findTypeAndMethodName(); |
| 135 | + if ($typeAndMethodName === null) { |
| 136 | + throw new \PHPStan\ShouldNotHappenException(); |
72 | 137 | }
|
73 |
| - $errorLine = $item->value->getLine(); |
74 |
| - $type = $this->getType($item->value, $scope); |
75 | 138 |
|
76 |
| - if ($type instanceof ConstantStringType) { |
77 |
| - if (!$type->isCallable()->yes()) { |
78 |
| - $errors[] = RuleErrorBuilder::message( |
79 |
| - sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
80 |
| - )->line($errorLine)->build(); |
81 |
| - continue; |
82 |
| - } |
83 |
| - // We can determine if the callback is callable through the type system. However, we cannot determine |
84 |
| - // if it is just a function or a static class call (MyClass::staticFunc). |
85 |
| - if ($this->reflectionProvider->hasFunction(new \PhpParser\Node\Name($type->getValue()), null)) { |
86 |
| - $errors[] = RuleErrorBuilder::message( |
87 |
| - sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
88 |
| - )->line($errorLine) |
89 |
| - ->tip('Change record: https://www.drupal.org/node/2966725.') |
90 |
| - ->build(); |
| 139 | + if (!$trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes()) { |
| 140 | + return RuleErrorBuilder::message( |
| 141 | + sprintf("%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $typeAndMethodName->getType()->describe(VerbosityLevel::value()), $pos) |
| 142 | + )->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build(); |
| 143 | + } |
| 144 | + } elseif ($type instanceof ClosureType) { |
| 145 | + if ($scope->isInClass()) { |
| 146 | + $classReflection = $scope->getClassReflection(); |
| 147 | + if ($classReflection === null) { |
| 148 | + throw new \PHPStan\ShouldNotHappenException(); |
91 | 149 | }
|
92 |
| - } elseif ($type instanceof ConstantArrayType) { |
93 |
| - if (!$type->isCallable()->yes()) { |
94 |
| - $errors[] = RuleErrorBuilder::message( |
95 |
| - sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 150 | + $classType = new ObjectType($classReflection->getName()); |
| 151 | + $formType = new ObjectType('\Drupal\Core\Form\FormInterface'); |
| 152 | + if ($formType->isSuperTypeOf($classType)->yes()) { |
| 153 | + return RuleErrorBuilder::message( |
| 154 | + sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos) |
96 | 155 | )->line($errorLine)->build();
|
97 |
| - continue; |
98 | 156 | }
|
99 |
| - $typeAndMethodName = $type->findTypeAndMethodName(); |
100 |
| - if ($typeAndMethodName === null) { |
101 |
| - throw new \PHPStan\ShouldNotHappenException(); |
| 157 | + } |
| 158 | + } elseif ($type instanceof ThisType) { |
| 159 | + if (!$type->isCallable()->yes()) { |
| 160 | + return RuleErrorBuilder::message( |
| 161 | + sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 162 | + )->line($errorLine)->build(); |
| 163 | + } |
| 164 | + } elseif ($type instanceof IntersectionType) { |
| 165 | + // Try to provide a tip for this weird occurrence. |
| 166 | + $tip = ''; |
| 167 | + if ($node instanceof Node\Expr\BinaryOp\Concat) { |
| 168 | + $leftStringType = $scope->getType($node->left)->toString(); |
| 169 | + $rightStringType = $scope->getType($node->right)->toString(); |
| 170 | + if ($leftStringType instanceof GenericClassStringType && $rightStringType instanceof ConstantStringType) { |
| 171 | + $methodName = str_replace(':', '', $rightStringType->getValue()); |
| 172 | + $tip = "Refactor concatenation of `static::class` with method name to an array callback: [static::class, '$methodName']"; |
102 | 173 | }
|
| 174 | + } |
103 | 175 |
|
104 |
| - if (!$trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes()) { |
105 |
| - $errors[] = RuleErrorBuilder::message( |
106 |
| - sprintf("%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $typeAndMethodName->getType()->describe(VerbosityLevel::value()), $pos) |
107 |
| - )->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build(); |
108 |
| - } |
109 |
| - } elseif ($type instanceof ClosureType) { |
110 |
| - if ($scope->isInClass()) { |
111 |
| - $classReflection = $scope->getClassReflection(); |
112 |
| - if ($classReflection === null) { |
113 |
| - throw new \PHPStan\ShouldNotHappenException(); |
114 |
| - } |
115 |
| - $classType = new ObjectType($classReflection->getName()); |
116 |
| - $formType = new ObjectType('\Drupal\Core\Form\FormInterface'); |
117 |
| - if ($formType->isSuperTypeOf($classType)->yes()) { |
118 |
| - $errors[] = RuleErrorBuilder::message( |
119 |
| - sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos) |
120 |
| - )->line($errorLine)->build(); |
121 |
| - } |
122 |
| - } |
123 |
| - } else { |
124 |
| - $errors[] = RuleErrorBuilder::message( |
125 |
| - sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
126 |
| - )->line($errorLine)->build(); |
| 176 | + if ($tip === '') { |
| 177 | + $tip = 'If this error is unexpected, open an issue with the error and sample code https://github.com/mglaman/phpstan-drupal/issues/new'; |
127 | 178 | }
|
| 179 | + |
| 180 | + return RuleErrorBuilder::message( |
| 181 | + sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 182 | + )->line($errorLine)->tip($tip)->build(); |
| 183 | + } else { |
| 184 | + return RuleErrorBuilder::message( |
| 185 | + sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos) |
| 186 | + )->line($errorLine)->build(); |
128 | 187 | }
|
129 | 188 |
|
130 |
| - return $errors; |
| 189 | + return null; |
131 | 190 | }
|
132 | 191 |
|
| 192 | + // @todo move to a helper, as Drupal uses `service:method` references a lot. |
133 | 193 | private function getType(Node\Expr $node, Scope $scope): Type
|
134 | 194 | {
|
135 | 195 | $type = $scope->getType($node);
|
|
0 commit comments