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