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