1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Rules;
4:
5: use PhpParser\Node\Expr;
6: use PHPStan\Analyser\Scope;
7: use PHPStan\DependencyInjection\AutowiredParameter;
8: use PHPStan\DependencyInjection\AutowiredService;
9: use PHPStan\Reflection\ParametersAcceptor;
10: use PHPStan\Reflection\ReflectionProvider;
11: use PHPStan\Type\BenevolentUnionType;
12: use PHPStan\Type\CallableType;
13: use PHPStan\Type\ClosureType;
14: use PHPStan\Type\ErrorType;
15: use PHPStan\Type\Generic\GenericObjectType;
16: use PHPStan\Type\Generic\TemplateMixedType;
17: use PHPStan\Type\Generic\TemplateType;
18: use PHPStan\Type\Generic\TemplateTypeVariance;
19: use PHPStan\Type\IntersectionType;
20: use PHPStan\Type\MixedType;
21: use PHPStan\Type\NeverType;
22: use PHPStan\Type\NullType;
23: use PHPStan\Type\StrictMixedType;
24: use PHPStan\Type\Type;
25: use PHPStan\Type\TypeCombinator;
26: use PHPStan\Type\TypeTraverser;
27: use PHPStan\Type\TypeUtils;
28: use PHPStan\Type\UnionType;
29: use function count;
30: use function sprintf;
31:
32: #[AutowiredService]
33: final class RuleLevelHelper
34: {
35:
36: public function __construct(
37: private ReflectionProvider $reflectionProvider,
38: #[AutowiredParameter]
39: private bool $checkNullables,
40: #[AutowiredParameter]
41: private bool $checkThisOnly,
42: #[AutowiredParameter]
43: private bool $checkUnionTypes,
44: #[AutowiredParameter]
45: private bool $checkExplicitMixed,
46: #[AutowiredParameter]
47: private bool $checkImplicitMixed,
48: #[AutowiredParameter]
49: private bool $checkBenevolentUnionTypes,
50: #[AutowiredParameter(ref: '%tips.discoveringSymbols%')]
51: private bool $discoveringSymbolsTip,
52: )
53: {
54: }
55:
56: /**
57: * @param callable(Type): Type $traverse
58: */
59: private function traverseWithoutMapping(Type $type, ?Type $acceptingType, callable $traverse): Type
60: {
61: if ($type instanceof GenericObjectType && $acceptingType !== null) {
62: return $type->traverseSimultaneously($acceptingType, function (Type $type, Type $acceptingType) use ($traverse): Type {
63: if (
64: !$this->checkNullables
65: && !$type instanceof BenevolentUnionType
66: && TypeCombinator::containsNull($type)
67: && !TypeCombinator::containsNull($acceptingType)
68: ) {
69: return $traverse(TypeCombinator::removeNull($type));
70: }
71:
72: return $type;
73: });
74: }
75:
76: return $traverse($type);
77: }
78:
79: /** @api */
80: public function isThis(Expr $expression): bool
81: {
82: return $expression instanceof Expr\Variable && $expression->name === 'this';
83: }
84:
85: private function transformCommonType(Type $type): Type
86: {
87: if (!$this->checkExplicitMixed && !$this->checkImplicitMixed) {
88: return $type;
89: }
90:
91: return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
92: if ($type instanceof GenericObjectType && !$type instanceof TemplateType) {
93: return $this->transformGenericObjectTypeArguments($type, $traverse);
94: }
95:
96: return $this->transformMixedType($type) ?? $traverse($type);
97: });
98: }
99:
100: private function transformMixedType(Type $type): ?Type
101: {
102: if ($type instanceof TemplateMixedType) {
103: if ($this->checkExplicitMixed) {
104: return $type->toStrictMixedType();
105: }
106: }
107: if (
108: $type instanceof MixedType
109: && (
110: ($type->isExplicitMixed() && $this->checkExplicitMixed)
111: || (!$type->isExplicitMixed() && $this->checkImplicitMixed)
112: )
113: ) {
114: return new StrictMixedType();
115: }
116:
117: return null;
118: }
119:
120: /**
121: * Invariant type arguments are compared with equals(), so mapping mixed nested in them
122: * only changes the outcome when one side is mapped and the other is not - like the
123: * type arguments an accepted object type inherits through @extends, which a traversal
124: * does not reach. A top-level mixed is still mapped, as variance checks treat it leniently.
125: *
126: * @param callable(Type): Type $traverse
127: */
128: private function transformGenericObjectTypeArguments(GenericObjectType $type, callable $traverse): Type
129: {
130: $classReflection = $type->getClassReflection();
131: if ($classReflection === null) {
132: return $traverse($type);
133: }
134:
135: $templateTypes = $classReflection->typeMapToList($classReflection->getTemplateTypeMap());
136: $variances = $type->getVariances();
137: $typesChanged = false;
138: $types = [];
139: foreach ($type->getTypes() as $i => $argument) {
140: $templateType = $templateTypes[$i] ?? null;
141: if (
142: ($variances[$i] ?? TemplateTypeVariance::createInvariant())->invariant()
143: && $templateType instanceof TemplateType
144: && $templateType->getVariance()->invariant()
145: ) {
146: $newArgument = $this->transformMixedType($argument) ?? $argument;
147: } else {
148: $newArgument = $traverse($argument);
149: }
150:
151: $types[] = $newArgument;
152: if ($newArgument === $argument) {
153: continue;
154: }
155:
156: $typesChanged = true;
157: }
158:
159: $subtractedType = $type->getSubtractedType();
160: $newSubtractedType = $subtractedType !== null ? $traverse($subtractedType) : null;
161: if (!$typesChanged && $newSubtractedType === $subtractedType) {
162: return $type;
163: }
164:
165: return new GenericObjectType($type->getClassName(), $types, $newSubtractedType, variances: $variances);
166: }
167:
168: /**
169: * @return array{Type, bool}
170: */
171: private function transformAcceptedType(Type $acceptingType, Type $acceptedType): array
172: {
173: $checkForUnion = $this->checkUnionTypes;
174: $acceptedType = TypeTraverser::mapMemoized($acceptedType, function (Type $acceptedType, callable $traverse) use ($acceptingType, &$checkForUnion): Type {
175: if ($acceptedType instanceof CallableType) {
176: if ($acceptedType->isCommonCallable()) {
177: return $acceptedType;
178: }
179:
180: $acceptingReturnType = $acceptingType instanceof ParametersAcceptor ? $acceptingType->getReturnType() : null;
181: return new CallableType(
182: $acceptedType->getParameters(),
183: $this->traverseWithoutMapping($acceptedType->getReturnType(), $acceptingReturnType, $traverse),
184: $acceptedType->isVariadic(),
185: $acceptedType->getTemplateTypeMap(),
186: $acceptedType->getResolvedTemplateTypeMap(),
187: $acceptedType->getTemplateTags(),
188: $acceptedType->isPure(),
189: );
190: }
191:
192: if ($acceptedType instanceof ClosureType) {
193: if ($acceptedType->isCommonCallable()) {
194: return $acceptedType;
195: }
196:
197: $acceptingReturnType = $acceptingType instanceof ParametersAcceptor ? $acceptingType->getReturnType() : null;
198: return new ClosureType(
199: $acceptedType->getParameters(),
200: $this->traverseWithoutMapping($acceptedType->getReturnType(), $acceptingReturnType, $traverse),
201: $acceptedType->isVariadic(),
202: $acceptedType->getTemplateTypeMap(),
203: $acceptedType->getResolvedTemplateTypeMap(),
204: $acceptedType->getCallSiteVarianceMap(),
205: $acceptedType->getTemplateTags(),
206: $acceptedType->getThrowPoints(),
207: $acceptedType->getImpurePoints(),
208: $acceptedType->getInvalidateExpressions(),
209: $acceptedType->getUsedVariables(),
210: $acceptedType->acceptsNamedArguments(),
211: $acceptedType->mustUseReturnValue(),
212: isStatic: $acceptedType->isStaticClosure(),
213: );
214: }
215:
216: if ($acceptedType instanceof GenericObjectType) {
217: return $this->traverseWithoutMapping($acceptedType, $acceptingType, $traverse);
218: }
219:
220: if (
221: !$this->checkNullables
222: && !$acceptingType instanceof NullType
223: && !$acceptedType instanceof NullType
224: && !$acceptedType instanceof BenevolentUnionType
225: ) {
226: return $traverse(TypeCombinator::removeNull($acceptedType));
227: }
228:
229: if ($this->checkBenevolentUnionTypes) {
230: if ($acceptedType instanceof BenevolentUnionType) {
231: $checkForUnion = true;
232: return $traverse(TypeUtils::toStrictUnion($acceptedType));
233: }
234: }
235:
236: return $traverse($acceptedType);
237: });
238:
239: return [$this->transformCommonType($acceptedType), $checkForUnion];
240: }
241:
242: /** @api */
243: public function accepts(Type $acceptingType, Type $acceptedType, bool $strictTypes): RuleLevelHelperAcceptsResult
244: {
245: [$acceptedType, $checkForUnion] = $this->transformAcceptedType($acceptingType, $acceptedType);
246: $acceptingType = $this->transformCommonType($acceptingType);
247:
248: $accepts = $acceptingType->accepts($acceptedType, $strictTypes);
249:
250: return new RuleLevelHelperAcceptsResult(
251: $checkForUnion ? $accepts->yes() : !$accepts->no(),
252: $accepts->reasons,
253: );
254: }
255:
256: /**
257: * @api
258: * @param callable(Type $type): bool $unionTypeCriteriaCallback
259: */
260: public function findTypeToCheck(
261: Scope $scope,
262: Expr $var,
263: string $unknownClassErrorPattern,
264: callable $unionTypeCriteriaCallback,
265: ): FoundTypeResult
266: {
267: if ($this->checkThisOnly && !$this->isThis($var)) {
268: return new FoundTypeResult(new ErrorType(), [], [], null);
269: }
270: $type = $scope->getType($var);
271:
272: return $this->findTypeToCheckImplementation($scope, $var, $type, $unknownClassErrorPattern, $unionTypeCriteriaCallback, true);
273: }
274:
275: /** @param callable(Type $type): bool $unionTypeCriteriaCallback */
276: private function findTypeToCheckImplementation(
277: Scope $scope,
278: Expr $var,
279: Type $type,
280: string $unknownClassErrorPattern,
281: callable $unionTypeCriteriaCallback,
282: bool $isTopLevel = false,
283: ): FoundTypeResult
284: {
285: if (
286: !$this->checkNullables
287: && !$type->isNull()->yes()
288: && !$unionTypeCriteriaCallback(new NullType())
289: ) {
290: $type = TypeCombinator::removeNull($type);
291: }
292:
293: if (
294: ($this->checkExplicitMixed || $this->checkImplicitMixed)
295: && $type instanceof MixedType
296: && ($type->isExplicitMixed() ? $this->checkExplicitMixed : $this->checkImplicitMixed)
297: ) {
298: return new FoundTypeResult(
299: $type instanceof TemplateMixedType
300: ? $type->toStrictMixedType()
301: : new StrictMixedType(),
302: [],
303: [],
304: null,
305: );
306: }
307:
308: if ($type instanceof MixedType || $type instanceof NeverType) {
309: return new FoundTypeResult(new ErrorType(), [], [], null);
310: }
311:
312: $errors = [];
313: $hasClassExistsClass = false;
314: $directClassNames = [];
315:
316: if ($isTopLevel) {
317: $directClassNames = $type->getObjectClassNames();
318: foreach ($directClassNames as $referencedClass) {
319: if ($this->reflectionProvider->hasClass($referencedClass)) {
320: $classReflection = $this->reflectionProvider->getClass($referencedClass);
321: if (!$classReflection->isTrait()) {
322: continue;
323: }
324: }
325:
326: if ($scope->isInClassExists($referencedClass)) {
327: $hasClassExistsClass = true;
328: continue;
329: }
330:
331: $errorBuilder = RuleErrorBuilder::message(sprintf($unknownClassErrorPattern, $referencedClass))
332: ->line($var->getStartLine())
333: ->identifier('class.notFound');
334:
335: if ($this->discoveringSymbolsTip) {
336: $errorBuilder->discoveringSymbolsTip();
337: }
338:
339: $errors[] = $errorBuilder->build();
340: }
341: }
342:
343: if (count($errors) > 0 || $hasClassExistsClass) {
344: return new FoundTypeResult(new ErrorType(), [], $errors, null);
345: }
346:
347: if (!$this->checkUnionTypes && $type->isObject()->yes() && count($type->getObjectClassNames()) === 0) {
348: return new FoundTypeResult(new ErrorType(), [], [], null);
349: }
350:
351: if ($type instanceof UnionType) {
352: $shouldFilterUnion = (
353: !$this->checkUnionTypes
354: && !$type instanceof BenevolentUnionType
355: ) || (
356: !$this->checkBenevolentUnionTypes
357: && $type instanceof BenevolentUnionType
358: );
359:
360: $newTypes = [];
361:
362: foreach ($type->getTypes() as $innerType) {
363: if ($shouldFilterUnion && !$unionTypeCriteriaCallback($innerType)) {
364: continue;
365: }
366:
367: $newTypes[] = $this->findTypeToCheckImplementation(
368: $scope,
369: $var,
370: $innerType,
371: $unknownClassErrorPattern,
372: $unionTypeCriteriaCallback,
373: )->getType();
374: }
375:
376: if (count($newTypes) > 0) {
377: $newUnion = TypeCombinator::union(...$newTypes);
378: if (
379: !$this->checkBenevolentUnionTypes
380: && $type instanceof BenevolentUnionType
381: ) {
382: $newUnion = TypeUtils::toBenevolentUnion($newUnion);
383: }
384:
385: return new FoundTypeResult($newUnion, $directClassNames, [], null);
386: }
387: }
388:
389: if ($type instanceof IntersectionType) {
390: $newTypes = [];
391:
392: $changed = false;
393: foreach ($type->getTypes() as $innerType) {
394: if ($innerType instanceof TemplateMixedType) {
395: $changed = true;
396: $newTypes[] = $this->findTypeToCheckImplementation(
397: $scope,
398: $var,
399: $innerType->toStrictMixedType(),
400: $unknownClassErrorPattern,
401: $unionTypeCriteriaCallback,
402: )->getType();
403: continue;
404: }
405: $newTypes[] = $innerType;
406: }
407:
408: if ($changed) {
409: return new FoundTypeResult(TypeCombinator::intersect(...$newTypes), $directClassNames, [], null);
410: }
411: }
412:
413: $tip = null;
414: if (
415: $type instanceof UnionType
416: && count($type->getTypes()) === 2
417: && $type->isObject()->yes()
418: && $type->getTypes()[0]->getObjectClassNames() === ['PhpParser\\Node\\Arg']
419: && $type->getTypes()[1]->getObjectClassNames() === ['PhpParser\\Node\\VariadicPlaceholder']
420: && !$unionTypeCriteriaCallback($type)
421: ) {
422: $tip = 'Use <fg=cyan>->getArgs()</> instead of <fg=cyan>->args</>.';
423: }
424:
425: return new FoundTypeResult($type, $directClassNames, [], $tip);
426: }
427:
428: }
429: