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