1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Testing;
4:
5: use LogicException;
6: use PhpParser\Node;
7: use PhpParser\Node\Expr\StaticCall;
8: use PhpParser\Node\Name;
9: use PHPStan\Analyser\ExpressionResultFactory;
10: use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
11: use PHPStan\Analyser\Fiber\FiberNodeScopeResolver;
12: use PHPStan\Analyser\MutatingScope;
13: use PHPStan\Analyser\NodeScopeResolver;
14: use PHPStan\Analyser\PerFileAnalysisResettable;
15: use PHPStan\Analyser\Scope;
16: use PHPStan\Analyser\ScopeContext;
17: use PHPStan\File\FileHelper;
18: use PHPStan\File\SystemAgnosticSimpleRelativePathHelper;
19: use PHPStan\Node\InClassNode;
20: use PHPStan\PhpDoc\PhpDocInheritanceResolver;
21: use PHPStan\PhpDoc\TypeStringResolver;
22: use PHPStan\Reflection\ClassReflectionFactory;
23: use PHPStan\Reflection\InitializerExprTypeResolver;
24: use PHPStan\Reflection\ReflectionProvider;
25: use PHPStan\Rules\Properties\ReadWritePropertiesExtension;
26: use PHPStan\ShouldNotHappenException;
27: use PHPStan\TrinaryLogic;
28: use PHPStan\Type\ConstantScalarType;
29: use PHPStan\Type\FileTypeMapper;
30: use PHPStan\Type\FunctionParameterClosureThisExtension;
31: use PHPStan\Type\FunctionParameterClosureTypeExtension;
32: use PHPStan\Type\FunctionParameterOutTypeExtension;
33: use PHPStan\Type\MethodParameterClosureThisExtension;
34: use PHPStan\Type\MethodParameterClosureTypeExtension;
35: use PHPStan\Type\MethodParameterOutTypeExtension;
36: use PHPStan\Type\StaticMethodParameterClosureThisExtension;
37: use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
38: use PHPStan\Type\StaticMethodParameterOutTypeExtension;
39: use PHPStan\Type\Type;
40: use PHPStan\Type\VerbosityLevel;
41: use Symfony\Component\Finder\Finder;
42: use function array_map;
43: use function array_merge;
44: use function count;
45: use function fclose;
46: use function fgets;
47: use function fopen;
48: use function getenv;
49: use function in_array;
50: use function is_dir;
51: use function is_string;
52: use function preg_match;
53: use function sprintf;
54: use function str_contains;
55: use function str_starts_with;
56: use function stripos;
57: use function strtolower;
58: use function version_compare;
59: use const PHP_VERSION;
60: use const PHP_VERSION_ID;
61:
62: /** @api */
63: abstract class TypeInferenceTestCase extends PHPStanTestCase
64: {
65:
66: protected static function createNodeScopeResolver(): NodeScopeResolver
67: {
68: $container = self::getContainer();
69: $reflectionProvider = self::createReflectionProvider();
70: $typeSpecifier = $container->getService('typeSpecifier');
71:
72: $enableFnsr = getenv('PHPSTAN_FNSR');
73: $className = NodeScopeResolver::class;
74: if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0') {
75: $className = FiberNodeScopeResolver::class;
76: }
77:
78: return new $className(
79: $container,
80: $reflectionProvider,
81: $container->getByType(InitializerExprTypeResolver::class),
82: self::getReflector(),
83: $container->getByType(ClassReflectionFactory::class),
84: $container->getExtensionsCollection(FunctionParameterOutTypeExtension::class),
85: $container->getExtensionsCollection(MethodParameterOutTypeExtension::class),
86: $container->getExtensionsCollection(StaticMethodParameterOutTypeExtension::class),
87: self::getParser(),
88: $container->getByType(FileTypeMapper::class),
89: $container->getByType(PhpDocInheritanceResolver::class),
90: $container->getByType(FileHelper::class),
91: $typeSpecifier,
92: $container->getExtensionsCollection(ReadWritePropertiesExtension::class),
93: $container->getExtensionsCollection(FunctionParameterClosureThisExtension::class),
94: $container->getExtensionsCollection(MethodParameterClosureThisExtension::class),
95: $container->getExtensionsCollection(StaticMethodParameterClosureThisExtension::class),
96: $container->getExtensionsCollection(FunctionParameterClosureTypeExtension::class),
97: $container->getExtensionsCollection(MethodParameterClosureTypeExtension::class),
98: $container->getExtensionsCollection(StaticMethodParameterClosureTypeExtension::class),
99: $container->getExtensionsCollection(PerFileAnalysisResettable::class),
100: self::createScopeFactory($reflectionProvider, $typeSpecifier),
101: $container->getParameter('polluteScopeWithLoopInitialAssignments'),
102: $container->getParameter('polluteScopeWithAlwaysIterableForeach'),
103: $container->getParameter('polluteScopeWithBlock'),
104: $container->getParameter('exceptions')['implicitThrows'],
105: $container->getParameter('treatPhpDocTypesAsCertain'),
106: $container->getByType(ImplicitToStringCallHelper::class),
107: $container->getByType(ExpressionResultFactory::class),
108: );
109: }
110:
111: /**
112: * @param string[] $dynamicConstantNames
113: */
114: protected static function createScope(
115: string $file,
116: array $dynamicConstantNames = [],
117: ): MutatingScope
118: {
119: $scopeFactory = self::createScopeFactory(self::createReflectionProvider(), self::getContainer()->getService('typeSpecifier'), $dynamicConstantNames);
120: return $scopeFactory->create(ScopeContext::create($file));
121: }
122:
123: /**
124: * @param callable(Node , Scope ): void $callback
125: * @param string[] $dynamicConstantNames
126: */
127: public static function processFile(
128: string $file,
129: callable $callback,
130: array $dynamicConstantNames = [],
131: ): void
132: {
133: $fileHelper = self::getContainer()->getByType(FileHelper::class);
134: $resolver = static::createNodeScopeResolver();
135: $resolver->setAnalysedFiles(array_map(static fn (string $file): string => $fileHelper->normalizePath($file), array_merge([$file], static::getAdditionalAnalysedFiles())));
136:
137: $resolver->processNodes(
138: self::getParser()->parseFile($file),
139: self::createScope($file, $dynamicConstantNames),
140: $callback,
141: );
142: }
143:
144: /**
145: * @api
146: * @param mixed ...$args
147: */
148: public function assertFileAsserts(
149: string $assertType,
150: string $file,
151: ...$args,
152: ): void
153: {
154: if ($assertType === 'type') {
155: if ($args[0] instanceof Type) {
156: // backward compatibility
157: $expectedType = $args[0];
158: $this->assertInstanceOf(ConstantScalarType::class, $expectedType);
159: $expected = $expectedType->getValue();
160: $actualType = $args[1];
161: $actual = $actualType->describe(VerbosityLevel::precise());
162: } else {
163: $expected = $args[0];
164: $actual = $args[1];
165: }
166:
167: $failureMessage = sprintf('Expected type %s, got type %s in %s on line %d.', $expected, $actual, $file, $args[2]);
168:
169: $delayedErrors = $args[3] ?? [];
170: if (count($delayedErrors) > 0) {
171: $failureMessage .= sprintf(
172: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
173: count($delayedErrors) === 1 ? 'issue' : 'issues',
174: );
175: foreach ($delayedErrors as $delayedError) {
176: $failureMessage .= sprintf("* %s\n", $delayedError);
177: }
178: }
179:
180: $this->assertSame(
181: $expected,
182: $actual,
183: $failureMessage,
184: );
185: } elseif ($assertType === 'superType') {
186: $expected = $args[0];
187: $actual = $args[1];
188: $isCorrect = $args[2];
189:
190: $failureMessage = sprintf('Expected subtype of %s, got type %s in %s on line %d.', $expected, $actual, $file, $args[3]);
191:
192: $delayedErrors = $args[4] ?? [];
193: if (count($delayedErrors) > 0) {
194: $failureMessage .= sprintf(
195: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
196: count($delayedErrors) === 1 ? 'issue' : 'issues',
197: );
198: foreach ($delayedErrors as $delayedError) {
199: $failureMessage .= sprintf("* %s\n", $delayedError);
200: }
201: }
202:
203: $this->assertTrue(
204: $isCorrect,
205: $failureMessage,
206: );
207: } elseif ($assertType === 'variableCertainty') {
208: $expectedCertainty = $args[0];
209: $actualCertainty = $args[1];
210: $variableName = $args[2];
211:
212: $failureMessage = sprintf('Expected %s, actual certainty of %s is %s in %s on line %d.', $expectedCertainty->describe(), $variableName, $actualCertainty->describe(), $file, $args[3]);
213: $delayedErrors = $args[4] ?? [];
214: if (count($delayedErrors) > 0) {
215: $failureMessage .= sprintf(
216: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
217: count($delayedErrors) === 1 ? 'issue' : 'issues',
218: );
219: foreach ($delayedErrors as $delayedError) {
220: $failureMessage .= sprintf("* %s\n", $delayedError);
221: }
222: }
223:
224: $this->assertTrue(
225: $expectedCertainty->equals($actualCertainty),
226: $failureMessage,
227: );
228: }
229: }
230:
231: /**
232: * @return array<string, (
233: * array{0: 'type', 1: string, 2: int|float|string|bool|null, 3: string, 4: int, 5?: non-empty-list<non-falsy-string>}|
234: * array{0: 'superType', 1: string, 2: string, 3: string, 4: bool, 5: int, 6?: non-empty-list<non-falsy-string>}|
235: * array{0: 'variableCertainty', 1: string, 2: TrinaryLogic, 3: TrinaryLogic, 4: string, 5: int, 6?: non-empty-list<non-falsy-string>}
236: * )>
237: *
238: * @api
239: */
240: public static function gatherAssertTypes(string $file): array
241: {
242: $fileHelper = self::getContainer()->getByType(FileHelper::class);
243:
244: $relativePathHelper = new SystemAgnosticSimpleRelativePathHelper($fileHelper);
245: $reflectionProvider = self::getContainer()->getByType(ReflectionProvider::class);
246: $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class);
247:
248: $file = $fileHelper->normalizePath($file);
249:
250: $asserts = [];
251: $delayedErrors = [];
252: self::processFile($file, static function (Node $node, Scope $scope) use (&$asserts, &$delayedErrors, $file, $relativePathHelper, $reflectionProvider, $typeStringResolver): void {
253: if ($node instanceof InClassNode) {
254: if (!$reflectionProvider->hasClass($node->getClassReflection()->getName())) {
255: $delayedErrors[] = sprintf(
256: '%s %s in %s not found in ReflectionProvider. Configure "autoload-dev" section in composer.json to include your tests directory.',
257: $node->getClassReflection()->getClassTypeDescription(),
258: $node->getClassReflection()->getName(),
259: $file,
260: );
261: }
262: } elseif ($node instanceof Node\Stmt\Trait_) {
263: if ($node->namespacedName === null) {
264: throw new ShouldNotHappenException();
265: }
266: if (!$reflectionProvider->hasClass($node->namespacedName->toString())) {
267: $delayedErrors[] = sprintf('Trait %s not found in ReflectionProvider. Configure "autoload-dev" section in composer.json to include your tests directory.', $node->namespacedName->toString());
268: }
269: }
270: if (!$node instanceof Node\Expr\FuncCall) {
271: return;
272: }
273:
274: $nameNode = $node->name;
275: if (!$nameNode instanceof Name) {
276: return;
277: }
278:
279: $functionName = $nameNode->toString();
280: if (in_array(strtolower($functionName), ['asserttype', 'assertnativetype', 'assertsupertype', 'assertvariablecertainty'], true)) {
281: self::fail(sprintf(
282: 'Missing use statement for %s() in %s on line %d.',
283: $functionName,
284: $relativePathHelper->getRelativePath($file),
285: $node->getStartLine(),
286: ));
287: } elseif ($functionName === 'PHPStan\\Testing\\assertType') {
288: $expectedType = $scope->getType($node->getArgs()[0]->value);
289: if (!$expectedType instanceof ConstantScalarType) {
290: self::fail(sprintf(
291: 'Expected type must be a literal string, %s given in %s on line %d.',
292: $expectedType->describe(VerbosityLevel::precise()),
293: $relativePathHelper->getRelativePath($file),
294: $node->getStartLine(),
295: ));
296: }
297: $actualType = $scope->getType($node->getArgs()[1]->value);
298: $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()];
299: } elseif ($functionName === 'PHPStan\\Testing\\assertNativeType') {
300: $expectedType = $scope->getType($node->getArgs()[0]->value);
301: if (!$expectedType instanceof ConstantScalarType) {
302: self::fail(sprintf(
303: 'Expected type must be a literal string, %s given in %s on line %d.',
304: $expectedType->describe(VerbosityLevel::precise()),
305: $relativePathHelper->getRelativePath($file),
306: $node->getStartLine(),
307: ));
308: }
309:
310: $actualType = $scope->getNativeType($node->getArgs()[1]->value);
311: $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()];
312: } elseif ($functionName === 'PHPStan\\Testing\\assertSuperType') {
313: $expectedType = $scope->getType($node->getArgs()[0]->value);
314: $expectedTypeStrings = $expectedType->getConstantStrings();
315: if (count($expectedTypeStrings) !== 1) {
316: self::fail(sprintf(
317: 'Expected super type must be a literal string, %s given in %s on line %d.',
318: $expectedType->describe(VerbosityLevel::precise()),
319: $relativePathHelper->getRelativePath($file),
320: $node->getStartLine(),
321: ));
322: }
323:
324: $actualType = $scope->getType($node->getArgs()[1]->value);
325: $isCorrect = $typeStringResolver->resolve($expectedTypeStrings[0]->getValue())->isSuperTypeOf($actualType)->yes();
326:
327: $assert = ['superType', $file, $expectedTypeStrings[0]->getValue(), $actualType->describe(VerbosityLevel::precise()), $isCorrect, $node->getStartLine()];
328: } elseif ($functionName === 'PHPStan\\Testing\\assertVariableCertainty') {
329: $certainty = $node->getArgs()[0]->value;
330: if (!$certainty instanceof StaticCall) {
331: self::fail(sprintf('First argument of %s() must be TrinaryLogic call', $functionName));
332: }
333: if (!$certainty->class instanceof Node\Name) {
334: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
335: }
336:
337: if ($certainty->class->toString() !== 'PHPStan\\TrinaryLogic') {
338: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
339: }
340:
341: if (!$certainty->name instanceof Node\Identifier) {
342: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
343: }
344:
345: // @phpstan-ignore staticMethod.dynamicName
346: $expectedertaintyValue = TrinaryLogic::{$certainty->name->toString()}();
347: $variable = $node->getArgs()[1]->value;
348: if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) {
349: $actualCertaintyValue = $scope->hasVariableType($variable->name);
350: $variableDescription = sprintf('variable $%s', $variable->name);
351: } elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) {
352: $offset = $scope->getType($variable->dim);
353: $actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset);
354: $variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise()));
355: } else {
356: self::fail(sprintf('ERROR: Invalid assertVariableCertainty call.'));
357: }
358:
359: $assert = ['variableCertainty', $file, $expectedertaintyValue, $actualCertaintyValue, $variableDescription, $node->getStartLine()];
360: } else {
361: $correctFunction = null;
362:
363: $assertFunctions = [
364: 'assertType' => 'PHPStan\\Testing\\assertType',
365: 'assertNativeType' => 'PHPStan\\Testing\\assertNativeType',
366: 'assertSuperType' => 'PHPStan\\Testing\\assertSuperType',
367: 'assertVariableCertainty' => 'PHPStan\\Testing\\assertVariableCertainty',
368: ];
369: foreach ($assertFunctions as $assertFn => $fqFunctionName) {
370: if (stripos($functionName, $assertFn) === false) {
371: continue;
372: }
373:
374: $correctFunction = $fqFunctionName;
375: }
376:
377: if ($correctFunction === null) {
378: return;
379: }
380:
381: self::fail(sprintf(
382: 'Function %s imported with wrong namespace %s called in %s on line %d.',
383: $correctFunction,
384: $functionName,
385: $relativePathHelper->getRelativePath($file),
386: $node->getStartLine(),
387: ));
388: }
389:
390: if (count($node->getArgs()) !== 2) {
391: self::fail(sprintf(
392: 'ERROR: Wrong %s() call in %s on line %d.',
393: $functionName,
394: $relativePathHelper->getRelativePath($file),
395: $node->getStartLine(),
396: ));
397: }
398:
399: $asserts[$file . ':' . $node->getStartLine()] = $assert;
400: });
401:
402: if (count($asserts) === 0) {
403: self::fail(sprintf('File %s does not contain any asserts', $file));
404: }
405:
406: if (count($delayedErrors) === 0) {
407: return $asserts;
408: }
409:
410: foreach ($asserts as $i => $assert) {
411: $assert[] = $delayedErrors;
412: $asserts[$i] = $assert;
413: }
414:
415: return $asserts;
416: }
417:
418: /**
419: * @api
420: * @return array<string, mixed[]>
421: */
422: public static function gatherAssertTypesFromDirectory(string $directory): array
423: {
424: $asserts = [];
425: foreach (self::findTestDataFilesFromDirectory($directory) as $path) {
426: foreach (self::gatherAssertTypes($path) as $key => $assert) {
427: $asserts[$key] = $assert;
428: }
429: }
430:
431: return $asserts;
432: }
433:
434: /**
435: * @return list<string>
436: */
437: public static function findTestDataFilesFromDirectory(string $directory): array
438: {
439: if (!is_dir($directory)) {
440: self::fail(sprintf('Directory %s does not exist.', $directory));
441: }
442:
443: $finder = new Finder();
444: $finder->followLinks();
445: $files = [];
446: foreach ($finder->files()->name('*.php')->in($directory) as $fileInfo) {
447: $path = $fileInfo->getPathname();
448: try {
449: if (self::isFileLintSkipped($path)) {
450: continue;
451: }
452: } catch (LogicException $e) {
453: self::fail($e->getMessage());
454: }
455: $files[] = $path;
456: }
457:
458: return $files;
459: }
460:
461: /**
462: * From https://github.com/php-parallel-lint/PHP-Parallel-Lint/blob/0c2706086ac36dce31967cb36062ff8915fe03f7/bin/skip-linting.php
463: *
464: * Copyright (c) 2012, Jakub Onderka
465: */
466: private static function isFileLintSkipped(string $file): bool
467: {
468: $f = @fopen($file, 'r');
469: if ($f !== false) {
470: $firstLine = fgets($f);
471: if ($firstLine === false) {
472: return false;
473: }
474:
475: // ignore shebang line
476: if (str_starts_with($firstLine, '#!')) {
477: $firstLine = fgets($f);
478: if ($firstLine === false) {
479: return false;
480: }
481: }
482:
483: @fclose($f);
484:
485: if (preg_match('~<?php\\s*\\/\\/\s*lint\s*([^\d\s]+)\s*([^\s]+)\s*~i', $firstLine, $m) === 1) {
486: return version_compare(PHP_VERSION, $m[2], $m[1]) === false;
487: } elseif (str_contains($firstLine, 'lint')) {
488: throw new LogicException(sprintf("'// lint' comment must immediately follow the php starting tag in %s on line 1", $file));
489: }
490: }
491:
492: return false;
493: }
494:
495: /** @return string[] */
496: protected static function getAdditionalAnalysedFiles(): array
497: {
498: return [];
499: }
500:
501: }
502: