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