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